diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index 2d9d5c1..dfb6127 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -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 }, diff --git a/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts b/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts new file mode 100644 index 0000000..2871e89 --- /dev/null +++ b/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts @@ -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 { + 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 { + 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(); + + 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, + })); +} diff --git a/apps/api/src/modules/admin/infrastructure/repositories/admin-user.queries.ts b/apps/api/src/modules/admin/infrastructure/repositories/admin-user.queries.ts new file mode 100644 index 0000000..4e227d0 --- /dev/null +++ b/apps/api/src/modules/admin/infrastructure/repositories/admin-user.queries.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts b/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts index 42919c3..2f7ff0b 100644 --- a/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts +++ b/apps/api/src/modules/admin/infrastructure/repositories/prisma-admin-query.repository.ts @@ -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 { - 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 { - 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(); - - 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 { - 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 { - 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 { diff --git a/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts new file mode 100644 index 0000000..0573761 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.commandBus.execute( + new RejectKycCommand(dto.userId, user.sub, dto.reason), + ); + } +} diff --git a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts index dcd6fbf..738d43f 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - return this.commandBus.execute( - new RejectKycCommand(dto.userId, user.sub, dto.reason), - ); - } - // ── Subscription Management ── @Post('subscriptions/adjust') diff --git a/apps/api/src/modules/admin/presentation/controllers/index.ts b/apps/api/src/modules/admin/presentation/controllers/index.ts index 19b5a1d..b0aaf06 100644 --- a/apps/api/src/modules/admin/presentation/controllers/index.ts +++ b/apps/api/src/modules/admin/presentation/controllers/index.ts @@ -1 +1,2 @@ export { AdminController } from './admin.controller'; +export { AdminModerationController } from './admin-moderation.controller'; diff --git a/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts b/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts index ed5ddb3..89b47ba 100644 --- a/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts +++ b/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/agents/application/__tests__/review-events.listener.spec.ts b/apps/api/src/modules/agents/application/__tests__/review-events.listener.spec.ts index 377a039..6801be7 100644 --- a/apps/api/src/modules/agents/application/__tests__/review-events.listener.spec.ts +++ b/apps/api/src/modules/agents/application/__tests__/review-events.listener.spec.ts @@ -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', () => { diff --git a/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts b/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts index a4cd922..43fbb82 100644 --- a/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts +++ b/apps/api/src/modules/agents/application/commands/recalculate-quality-score/recalculate-quality-score.handler.ts @@ -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 { - 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 { 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', ); } } diff --git a/apps/api/src/modules/agents/application/listeners/review-events.listener.ts b/apps/api/src/modules/agents/application/listeners/review-events.listener.ts index 04c6821..a2fc8a3 100644 --- a/apps/api/src/modules/agents/application/listeners/review-events.listener.ts +++ b/apps/api/src/modules/agents/application/listeners/review-events.listener.ts @@ -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), diff --git a/apps/api/src/modules/analytics/application/__tests__/listing-created-moderation.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/listing-created-moderation.handler.spec.ts index 3894d7e..83ea096 100644 --- a/apps/api/src/modules/analytics/application/__tests__/listing-created-moderation.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/listing-created-moderation.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/analytics/application/__tests__/track-event.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/track-event.handler.spec.ts index 0cda970..b5eafa2 100644 --- a/apps/api/src/modules/analytics/application/__tests__/track-event.handler.spec.ts +++ b/apps/api/src/modules/analytics/application/__tests__/track-event.handler.spec.ts @@ -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 () => { diff --git a/apps/api/src/modules/analytics/application/commands/track-event/track-event.handler.ts b/apps/api/src/modules/analytics/application/commands/track-event/track-event.handler.ts index 5ad9b20..1039ac8 100644 --- a/apps/api/src/modules/analytics/application/commands/track-event/track-event.handler.ts +++ b/apps/api/src/modules/analytics/application/commands/track-event/track-event.handler.ts @@ -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 { - private readonly logger = new Logger(TrackEventHandler.name); + constructor(private readonly logger: LoggerService) {} async execute(command: TrackEventCommand): Promise { this.logger.log( `Analytics event: ${command.eventType} on ${command.entityType}:${command.entityId}`, + 'TrackEventHandler', ); return { diff --git a/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts b/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts index 19de18f..3ad8fdd 100644 --- a/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts +++ b/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts @@ -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 { - 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 { @@ -26,6 +25,7 @@ export class ListingCreatedModerationHandler implements IEventHandler f.category).join(', ')})`, + 'ListingCreatedModerationHandler', ); const flagNotes = result.flags diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/http-avm.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/http-avm.service.spec.ts index 42db0e8..4e802ca 100644 --- a/apps/api/src/modules/analytics/infrastructure/__tests__/http-avm.service.spec.ts +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/http-avm.service.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts index 8c25290..d030686 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts @@ -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; diff --git a/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts index 6f83b6a..d83e295 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts @@ -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 { @@ -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); } diff --git a/apps/api/src/modules/analytics/infrastructure/services/market-index-cron.service.ts b/apps/api/src/modules/analytics/infrastructure/services/market-index-cron.service.ts index 1bfc134..3c28472 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/market-index-cron.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/market-index-cron.service.ts @@ -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 { - 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'); } } diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/oauth.service.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/oauth.service.spec.ts index 17c72f8..d7133eb 100644 --- a/apps/api/src/modules/auth/infrastructure/__tests__/oauth.service.spec.ts +++ b/apps/api/src/modules/auth/infrastructure/__tests__/oauth.service.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts index 8df4c74..402c9d4 100644 --- a/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts +++ b/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts @@ -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'); }); diff --git a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts index 81eba3c..7488894 100644 --- a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts +++ b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts @@ -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, diff --git a/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts index a6b2f0a..1328293 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts @@ -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'}`); } diff --git a/apps/api/src/modules/auth/presentation/guards/roles.guard.ts b/apps/api/src/modules/auth/presentation/guards/roles.guard.ts index 607f46e..093bbe1 100644 --- a/apps/api/src/modules/auth/presentation/guards/roles.guard.ts +++ b/apps/api/src/modules/auth/presentation/guards/roles.guard.ts @@ -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(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', ); } diff --git a/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts index fc34169..88d5204 100644 --- a/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts +++ b/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/inquiries/application/__tests__/mark-inquiry-read.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/mark-inquiry-read.handler.spec.ts index 12e5028..3dfa773 100644 --- a/apps/api/src/modules/inquiries/application/__tests__/mark-inquiry-read.handler.spec.ts +++ b/apps/api/src/modules/inquiries/application/__tests__/mark-inquiry-read.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts index 542c07e..605a7b1 100644 --- a/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts +++ b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts @@ -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 { - 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 { @@ -49,7 +48,7 @@ export class CreateInquiryHandler implements ICommandHandler { - 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 { @@ -46,6 +45,6 @@ export class MarkInquiryReadHandler implements ICommandHandler { 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, ); }); diff --git a/apps/api/src/modules/leads/application/__tests__/delete-lead.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/delete-lead.handler.spec.ts index c2833f7..54e5538 100644 --- a/apps/api/src/modules/leads/application/__tests__/delete-lead.handler.spec.ts +++ b/apps/api/src/modules/leads/application/__tests__/delete-lead.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/leads/application/__tests__/update-lead-status.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/update-lead-status.handler.spec.ts index 06d9350..068feea 100644 --- a/apps/api/src/modules/leads/application/__tests__/update-lead-status.handler.spec.ts +++ b/apps/api/src/modules/leads/application/__tests__/update-lead-status.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/leads/application/commands/create-lead/create-lead.handler.ts b/apps/api/src/modules/leads/application/commands/create-lead/create-lead.handler.ts index 439315c..65d6178 100644 --- a/apps/api/src/modules/leads/application/commands/create-lead/create-lead.handler.ts +++ b/apps/api/src/modules/leads/application/commands/create-lead/create-lead.handler.ts @@ -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 { - 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 { @@ -62,7 +61,7 @@ export class CreateLeadHandler implements ICommandHandler { 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, diff --git a/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.handler.ts b/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.handler.ts index df24547..f02d42a 100644 --- a/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.handler.ts +++ b/apps/api/src/modules/leads/application/commands/delete-lead/delete-lead.handler.ts @@ -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 { - 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 { @@ -35,6 +34,6 @@ export class DeleteLeadHandler implements ICommandHandler { 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'); } } diff --git a/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.handler.ts b/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.handler.ts index a3f867b..2edb31c 100644 --- a/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.handler.ts +++ b/apps/api/src/modules/leads/application/commands/update-lead-status/update-lead-status.handler.ts @@ -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 { - 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 { @@ -43,6 +42,6 @@ export class UpdateLeadStatusHandler implements ICommandHandler { 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, ); }); diff --git a/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts b/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts index 3563992..dfd6d05 100644 --- a/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts @@ -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 () => { diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts index fabb056..eb9b280 100644 --- a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts @@ -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 { - 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 { @@ -141,7 +140,7 @@ export class CreateListingHandler implements ICommandHandler { + 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> { + 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> { + 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), + }; +} diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts index a3f538f..a7661bf 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts @@ -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 { - 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 { @@ -124,79 +70,7 @@ export class PrismaListingRepository implements IListingRepository { } async search(params: ListingSearchParams): Promise> { - 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> { @@ -204,44 +78,7 @@ export class PrismaListingRepository implements IListingRepository { } async findBySellerId(sellerId: string, page: number, limit: number): Promise> { - 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 { diff --git a/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts b/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts index 3ebe0cd..87c5190 100644 --- a/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts +++ b/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts @@ -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 { 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; } } diff --git a/apps/api/src/modules/payments/application/__tests__/create-payment.handler.spec.ts b/apps/api/src/modules/payments/application/__tests__/create-payment.handler.spec.ts index cb4473c..7ef657a 100644 --- a/apps/api/src/modules/payments/application/__tests__/create-payment.handler.spec.ts +++ b/apps/api/src/modules/payments/application/__tests__/create-payment.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/payments/application/__tests__/handle-callback-edge-cases.handler.spec.ts b/apps/api/src/modules/payments/application/__tests__/handle-callback-edge-cases.handler.spec.ts index 5da3644..4cf7c96 100644 --- a/apps/api/src/modules/payments/application/__tests__/handle-callback-edge-cases.handler.spec.ts +++ b/apps/api/src/modules/payments/application/__tests__/handle-callback-edge-cases.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/payments/application/__tests__/handle-callback.handler.spec.ts b/apps/api/src/modules/payments/application/__tests__/handle-callback.handler.spec.ts index 7947601..54fb831 100644 --- a/apps/api/src/modules/payments/application/__tests__/handle-callback.handler.spec.ts +++ b/apps/api/src/modules/payments/application/__tests__/handle-callback.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/payments/application/__tests__/refund-payment.handler.spec.ts b/apps/api/src/modules/payments/application/__tests__/refund-payment.handler.spec.ts index 18721d9..4b0ac10 100644 --- a/apps/api/src/modules/payments/application/__tests__/refund-payment.handler.spec.ts +++ b/apps/api/src/modules/payments/application/__tests__/refund-payment.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts b/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts index 7a6dbf5..6fea201 100644 --- a/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts +++ b/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts @@ -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 { - 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 { @@ -86,6 +85,7 @@ export class CreatePaymentHandler implements ICommandHandler { - 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 { @@ -37,6 +36,7 @@ export class HandleCallbackHandler implements ICommandHandler { - 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 { @@ -59,6 +58,7 @@ export class RefundPaymentHandler implements ICommandHandler { 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 = {}): Record { diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts index d98792a..cf6b492 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts @@ -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 () => { diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts index 63c3e68..d01223c 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts @@ -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( diff --git a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts index 5e7b4a6..e321689 100644 --- a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts @@ -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('MOMO_PARTNER_CODE'); this.accessKey = this.config.getOrThrow('MOMO_ACCESS_KEY'); this.secretKey = this.config.getOrThrow('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 }; } } diff --git a/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts b/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts index b0de28e..c634da0 100644 --- a/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts @@ -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('VNPAY_TMN_CODE'); this.hashSecret = this.config.getOrThrow('VNPAY_HASH_SECRET'); this.baseUrl = this.config.get('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 }; } } diff --git a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts index ae98e8b..7765386 100644 --- a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts @@ -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('ZALOPAY_APP_ID'); this.key1 = this.config.getOrThrow('ZALOPAY_KEY1'); this.key2 = this.config.getOrThrow('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 }; } } diff --git a/apps/api/src/modules/reviews/application/__tests__/create-review.handler.spec.ts b/apps/api/src/modules/reviews/application/__tests__/create-review.handler.spec.ts index 410d0a9..9c3b2a3 100644 --- a/apps/api/src/modules/reviews/application/__tests__/create-review.handler.spec.ts +++ b/apps/api/src/modules/reviews/application/__tests__/create-review.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/reviews/application/__tests__/delete-review.handler.spec.ts b/apps/api/src/modules/reviews/application/__tests__/delete-review.handler.spec.ts index e4f2c96..947812c 100644 --- a/apps/api/src/modules/reviews/application/__tests__/delete-review.handler.spec.ts +++ b/apps/api/src/modules/reviews/application/__tests__/delete-review.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts b/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts index 630adaf..cb4a828 100644 --- a/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts +++ b/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts @@ -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 { - 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 { @@ -65,7 +64,7 @@ export class CreateReviewHandler implements ICommandHandler 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, diff --git a/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts index ca1bf83..35a87a0 100644 --- a/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts +++ b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts @@ -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 { - 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 { @@ -32,6 +31,6 @@ export class DeleteReviewHandler implements ICommandHandler 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'); } } diff --git a/apps/api/src/modules/subscriptions/application/__tests__/cancel-subscription.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/cancel-subscription.handler.spec.ts index 721989b..fab027c 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/cancel-subscription.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/cancel-subscription.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts index 923f985..55b7b1c 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts index b69773d..fb91f5d 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/subscriptions/application/__tests__/upgrade-subscription.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/upgrade-subscription.handler.spec.ts index 96d135d..16eac6b 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/upgrade-subscription.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/upgrade-subscription.handler.spec.ts @@ -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, ); }); diff --git a/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts index 70d5fd3..301cd8c 100644 --- a/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts @@ -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 { - 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 { @@ -47,6 +46,7 @@ export class CancelSubscriptionHandler implements ICommandHandler { - 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 { @@ -72,6 +71,7 @@ export class CreateSubscriptionHandler implements ICommandHandler { - 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 { @@ -75,6 +74,7 @@ export class MeterUsageHandler implements ICommandHandler { this.logger.log( `Usage metered: subscription=${subscription.id}, metric=${command.metric}, count=${command.count}`, + 'MeterUsageHandler', ); return { diff --git a/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts index fd62b3e..b6d983d 100644 --- a/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts @@ -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 { - 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 { @@ -80,6 +79,7 @@ export class UpgradeSubscriptionHandler implements ICommandHandler { 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 () => { diff --git a/apps/api/src/modules/subscriptions/infrastructure/event-handlers/listing-created-usage.handler.ts b/apps/api/src/modules/subscriptions/infrastructure/event-handlers/listing-created-usage.handler.ts index e983603..d4f0794 100644 --- a/apps/api/src/modules/subscriptions/infrastructure/event-handlers/listing-created-usage.handler.ts +++ b/apps/api/src/modules/subscriptions/infrastructure/event-handlers/listing-created-usage.handler.ts @@ -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 { 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', ); } }