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 43fbb82..c4e41f1 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,11 +1,12 @@ -import { Inject } from '@nestjs/common'; -import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type PrismaService, type LoggerService } from '@modules/shared'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService } from '@modules/shared'; import { AGENT_REPOSITORY, type IAgentRepository, } from '../../../domain/repositories/agent.repository'; import { QualityScoreCalculator } from '../../../domain/services/quality-score.service'; +import { QualityScore } from '../../../domain/value-objects/quality-score.vo'; import { RecalculateQualityScoreCommand } from './recalculate-quality-score.command'; @CommandHandler(RecalculateQualityScoreCommand) @@ -15,70 +16,48 @@ export class RecalculateQualityScoreHandler constructor( @Inject(AGENT_REPOSITORY) private readonly agentRepo: IAgentRepository, - private readonly prisma: PrismaService, + private readonly eventBus: EventBus, 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`, 'RecalculateQualityScoreHandler'); - return; + try { + const agent = await this.agentRepo.findById(command.agentId); + if (!agent) { + this.logger.warn(`Agent ${command.agentId} not found, skipping recalculation`, 'RecalculateQualityScoreHandler'); + return; + } + + const inputs = await this.agentRepo.getQualityScoreInputs(command.agentId); + + const rawScore = QualityScoreCalculator.calculate(inputs); + const newScore = QualityScore.fromPersistence(rawScore); + + agent.updateQualityScore(newScore); + + await this.agentRepo.save(agent); + + // Publish domain events + const events = agent.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Quality score recalculated for agent ${command.agentId}: ${rawScore} ` + + `(rating=${inputs.avgRating.toFixed(2)}, reviews=${inputs.totalReviews}, ` + + `conversion=${(inputs.conversionRate * 100).toFixed(1)}%, ` + + `activeListingRatio=${(inputs.activeListingRatio * 100).toFixed(1)}%)`, + 'RecalculateQualityScoreHandler', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to recalculate quality score for agent ${command.agentId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tính lại điểm chất lượng môi giới'); } - - // Fetch review stats for this agent - const reviewStats = await this.prisma.review.aggregate({ - where: { targetType: 'AGENT', targetId: command.agentId }, - _avg: { rating: true }, - _count: { rating: true }, - }); - - const avgRating = reviewStats._avg.rating ?? 0; - const totalReviews = reviewStats._count.rating; - - // Fetch lead conversion rate - const [totalLeads, convertedLeads] = await Promise.all([ - this.prisma.lead.count({ where: { agentId: command.agentId } }), - this.prisma.lead.count({ - where: { agentId: command.agentId, status: 'CONVERTED' }, - }), - ]); - const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0; - - // Fetch listing activity ratio - const [totalListings, activeListings] = await Promise.all([ - this.prisma.listing.count({ - where: { agentId: command.agentId }, - }), - this.prisma.listing.count({ - where: { agentId: command.agentId, status: 'ACTIVE' }, - }), - ]); - const activeListingRatio = - totalListings > 0 ? activeListings / totalListings : 0; - - // Fetch response time from agent record - const agentRecord = await this.prisma.agent.findUnique({ - where: { id: command.agentId }, - select: { responseTimeAvg: true }, - }); - - const score = QualityScoreCalculator.calculate({ - avgRating, - totalReviews, - responseTimeAvg: agentRecord?.responseTimeAvg ?? null, - conversionRate, - activeListingRatio, - }); - - await this.agentRepo.updateQualityScore(command.agentId, score); - - this.logger.log( - `Quality score recalculated for agent ${command.agentId}: ${score} ` + - `(rating=${avgRating.toFixed(2)}, reviews=${totalReviews}, ` + - `conversion=${(conversionRate * 100).toFixed(1)}%, ` + - `activeListings=${activeListings}/${totalListings})`, - 'RecalculateQualityScoreHandler', - ); } } diff --git a/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.handler.ts b/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.handler.ts index 8c6a54c..a990cc8 100644 --- a/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.handler.ts +++ b/apps/api/src/modules/agents/application/queries/get-agent-dashboard/get-agent-dashboard.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { NotFoundException } from '@modules/shared'; +import { DomainException, NotFoundException, type LoggerService } from '@modules/shared'; import { AGENT_REPOSITORY, type AgentDashboardData, @@ -15,14 +15,25 @@ export class GetAgentDashboardHandler constructor( @Inject(AGENT_REPOSITORY) private readonly agentRepo: IAgentRepository, + private readonly logger: LoggerService, ) {} async execute(query: GetAgentDashboardQuery): Promise { - const agent = await this.agentRepo.findByUserId(query.userId); - if (!agent) { - throw new NotFoundException('Không tìm thấy thông tin môi giới'); - } + try { + const agent = await this.agentRepo.findByUserId(query.userId); + if (!agent) { + throw new NotFoundException('Không tìm thấy thông tin môi giới'); + } - return this.agentRepo.getDashboard(agent.id); + return this.agentRepo.getDashboard(agent.id); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get agent dashboard: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tải bảng điều khiển môi giới'); + } } } diff --git a/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.handler.ts b/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.handler.ts index ddc3154..a172770 100644 --- a/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.handler.ts +++ b/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.handler.ts @@ -1,5 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService } from '@modules/shared'; import { AGENT_REPOSITORY, type AgentPublicProfileData, @@ -14,11 +15,22 @@ export class GetAgentPublicProfileHandler constructor( @Inject(AGENT_REPOSITORY) private readonly agentRepo: IAgentRepository, + private readonly logger: LoggerService, ) {} async execute( query: GetAgentPublicProfileQuery, ): Promise { - return this.agentRepo.getPublicProfile(query.agentId); + try { + return this.agentRepo.getPublicProfile(query.agentId); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get agent public profile: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tải hồ sơ công khai của môi giới'); + } } } diff --git a/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.handler.ts b/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.handler.ts index 4273d68..426bbc2 100644 --- a/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.handler.ts +++ b/apps/api/src/modules/analytics/application/commands/generate-report/generate-report.handler.ts @@ -1,5 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, @@ -18,20 +19,31 @@ export interface GenerateReportResult { export class GenerateReportHandler implements ICommandHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, + private readonly logger: LoggerService, ) {} async execute(command: GenerateReportCommand): Promise { - const data = await this.marketIndexRepo.getMarketReport( - command.city, - command.period, - command.propertyType, - ); + try { + const data = await this.marketIndexRepo.getMarketReport( + command.city, + command.period, + command.propertyType, + ); - return { - city: command.city, - period: command.period, - data, - generatedAt: new Date().toISOString(), - }; + return { + city: command.city, + period: command.period, + data, + generatedAt: new Date().toISOString(), + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to tạo báo cáo thị trường: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tạo báo cáo thị trường. Vui lòng thử lại sau.'); + } } } 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 1039ac8..aed22ad 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,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type LoggerService } from '@modules/shared'; +import { DomainException, type LoggerService } from '@modules/shared'; import { TrackEventCommand } from './track-event.command'; export interface TrackEventResult { @@ -12,14 +13,24 @@ export class TrackEventHandler implements ICommandHandler { constructor(private readonly logger: LoggerService) {} async execute(command: TrackEventCommand): Promise { - this.logger.log( - `Analytics event: ${command.eventType} on ${command.entityType}:${command.entityId}`, - 'TrackEventHandler', - ); + try { + this.logger.log( + `Analytics event: ${command.eventType} on ${command.entityType}:${command.entityId}`, + 'TrackEventHandler', + ); - return { - tracked: true, - eventType: command.eventType, - }; + return { + tracked: true, + eventType: command.eventType, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to ghi nhận sự kiện phân tích: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể ghi nhận sự kiện phân tích. Vui lòng thử lại sau.'); + } } } diff --git a/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts index 6561ec1..4b6bd95 100644 --- a/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts +++ b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type CacheService, CachePrefix } from '@modules/shared'; +import { DomainException, type CacheService, CachePrefix, type LoggerService } from '@modules/shared'; import { MarketIndexEntity } from '../../../domain/entities/market-index.entity'; import { MARKET_INDEX_REPOSITORY, @@ -18,18 +18,40 @@ export class UpdateMarketIndexHandler implements ICommandHandler { - const existing = await this.marketIndexRepo.findByKey( - command.district, - command.city, - command.propertyType, - command.period, - ); + try { + const existing = await this.marketIndexRepo.findByKey( + command.district, + command.city, + command.propertyType, + command.period, + ); - if (existing) { - existing.updateMetrics( + if (existing) { + existing.updateMetrics( + command.medianPrice, + command.avgPriceM2, + command.totalListings, + command.daysOnMarket, + command.inventoryLevel, + command.absorptionRate, + command.yoyChange, + ); + await this.marketIndexRepo.update(existing); + await this.invalidateMarketCaches(); + return { id: existing.id, created: false }; + } + + const id = crypto.randomUUID(); + const entity = MarketIndexEntity.createNew( + id, + command.district, + command.city, + command.propertyType, + command.period, command.medianPrice, command.avgPriceM2, command.totalListings, @@ -38,32 +60,21 @@ export class UpdateMarketIndexHandler implements ICommandHandler { diff --git a/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts index ddc038e..508c700 100644 --- a/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { type CacheService, CachePrefix, CacheTTL, Cacheable } from '@modules/shared'; +import { DomainException, type CacheService, CachePrefix, CacheTTL, Cacheable, type LoggerService } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, @@ -19,6 +19,7 @@ export class GetDistrictStatsHandler implements IQueryHandler { - const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period); - return { city: query.city, period: query.period, districts }; + try { + const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period); + return { city: query.city, period: query.period, districts }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to truy vấn thống kê quận/huyện: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể truy vấn thống kê quận/huyện. Vui lòng thử lại sau.'); + } } } diff --git a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts index fd2b32c..9f715fe 100644 --- a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, @@ -19,19 +19,30 @@ export class GetHeatmapHandler implements IQueryHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, private readonly cache: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: GetHeatmapQuery): Promise { - const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period); + try { + const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period); - return this.cache.getOrSet( - cacheKey, - async () => { - const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period); - return { city: query.city, period: query.period, dataPoints }; - }, - CacheTTL.HEATMAP, - 'heatmap', - ); + return this.cache.getOrSet( + cacheKey, + async () => { + const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period); + return { city: query.city, period: query.period, dataPoints }; + }, + CacheTTL.HEATMAP, + 'heatmap', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to truy vấn dữ liệu bản đồ nhiệt: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể truy vấn dữ liệu bản đồ nhiệt. Vui lòng thử lại sau.'); + } } } diff --git a/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts index db58488..3fb0697 100644 --- a/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, @@ -19,23 +19,34 @@ export class GetMarketReportHandler implements IQueryHandler { - const cacheKey = CacheService.buildKey(CachePrefix.MARKET_REPORT, query.city, query.period, query.propertyType); + try { + const cacheKey = CacheService.buildKey(CachePrefix.MARKET_REPORT, query.city, query.period, query.propertyType); - return this.cache.getOrSet( - cacheKey, - async () => { - const districts = await this.marketIndexRepo.getMarketReport( - query.city, - query.period, - query.propertyType, - ); - return { city: query.city, period: query.period, districts }; - }, - CacheTTL.MARKET_REPORT, - 'market_report', - ); + return this.cache.getOrSet( + cacheKey, + async () => { + const districts = await this.marketIndexRepo.getMarketReport( + query.city, + query.period, + query.propertyType, + ); + return { city: query.city, period: query.period, districts }; + }, + CacheTTL.MARKET_REPORT, + 'market_report', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to truy vấn báo cáo thị trường: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể truy vấn báo cáo thị trường. Vui lòng thử lại sau.'); + } } } diff --git a/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts index ff3a213..29b8b29 100644 --- a/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, @@ -20,30 +20,41 @@ export class GetPriceTrendHandler implements IQueryHandler { constructor( @Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository, private readonly cache: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: GetPriceTrendQuery): Promise { - const cacheKey = CacheService.buildKey( - CachePrefix.MARKET_TREND, - query.district, - query.city, - query.propertyType, - query.periods?.join(','), - ); + try { + const cacheKey = CacheService.buildKey( + CachePrefix.MARKET_TREND, + query.district, + query.city, + query.propertyType, + query.periods?.join(','), + ); - return this.cache.getOrSet( - cacheKey, - async () => { - const trend = await this.marketIndexRepo.getPriceTrend( - query.district, - query.city, - query.propertyType, - query.periods, - ); - return { district: query.district, city: query.city, propertyType: query.propertyType, trend }; - }, - CacheTTL.MARKET_DATA, - 'price_trend', - ); + return this.cache.getOrSet( + cacheKey, + async () => { + const trend = await this.marketIndexRepo.getPriceTrend( + query.district, + query.city, + query.propertyType, + query.periods, + ); + return { district: query.district, city: query.city, propertyType: query.propertyType, trend }; + }, + CacheTTL.MARKET_DATA, + 'price_trend', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to truy vấn xu hướng giá: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể truy vấn xu hướng giá. Vui lòng thử lại sau.'); + } } } diff --git a/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.handler.ts b/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.handler.ts index db149bb..d606f3f 100644 --- a/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared'; import { AVM_SERVICE, type IAVMService, @@ -15,31 +15,42 @@ export class GetValuationHandler implements IQueryHandler { constructor( @Inject(AVM_SERVICE) private readonly avmService: IAVMService, private readonly cache: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: GetValuationQuery): Promise { - const cacheKey = CacheService.buildKey( - CachePrefix.VALUATION, - query.propertyId ?? '', - query.latitude?.toString(), - query.longitude?.toString(), - query.areaM2?.toString(), - query.propertyType, - ); + try { + const cacheKey = CacheService.buildKey( + CachePrefix.VALUATION, + query.propertyId ?? '', + query.latitude?.toString(), + query.longitude?.toString(), + query.areaM2?.toString(), + query.propertyType, + ); - return this.cache.getOrSet( - cacheKey, - async () => { - return this.avmService.estimateValue({ - propertyId: query.propertyId, - latitude: query.latitude, - longitude: query.longitude, - areaM2: query.areaM2, - propertyType: query.propertyType, - }); - }, - CacheTTL.MARKET_DATA, - 'valuation', - ); + return this.cache.getOrSet( + cacheKey, + async () => { + return this.avmService.estimateValue({ + propertyId: query.propertyId, + latitude: query.latitude, + longitude: query.longitude, + areaM2: query.areaM2, + propertyType: query.propertyType, + }); + }, + CacheTTL.MARKET_DATA, + 'valuation', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to định giá bất động sản: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể định giá bất động sản. Vui lòng thử lại sau.'); + } } } diff --git a/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.handler.ts b/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.handler.ts index 7ecab7c..7b95c15 100644 --- a/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.handler.ts +++ b/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.handler.ts @@ -1,5 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type LoggerService, type PrismaService, NotFoundException, ValidationException } from '@modules/shared'; +import { type LoggerService, type PrismaService, DomainException, NotFoundException, ValidationException } from '@modules/shared'; import { CancelUserDeletionCommand } from './cancel-user-deletion.command'; @CommandHandler(CancelUserDeletionCommand) @@ -10,17 +11,27 @@ export class CancelUserDeletionHandler implements ICommandHandler { - const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); - if (!user) throw new NotFoundException('User', command.userId); - if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa vĩnh viễn'); - if (!user.deletionScheduledAt) throw new ValidationException('Không có yêu cầu xóa nào đang chờ'); + try { + const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); + if (!user) throw new NotFoundException('User', command.userId); + if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa vĩnh viễn'); + if (!user.deletionScheduledAt) throw new ValidationException('Không có yêu cầu xóa nào đang chờ'); - await this.prisma.user.update({ - where: { id: command.userId }, - data: { deletionScheduledAt: null, isActive: true }, - }); + await this.prisma.user.update({ + where: { id: command.userId }, + data: { deletionScheduledAt: null, isActive: true }, + }); - this.logger.log(`User ${command.userId} deletion cancelled`, 'CancelUserDeletionHandler'); - return { message: 'Đã hủy yêu cầu xóa tài khoản' }; + this.logger.log(`User ${command.userId} deletion cancelled`, 'CancelUserDeletionHandler'); + return { message: 'Đã hủy yêu cầu xóa tài khoản' }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to cancel user deletion: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể hủy yêu cầu xóa tài khoản'); + } } } diff --git a/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.handler.ts b/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.handler.ts index 6e999e7..d1997aa 100644 --- a/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.handler.ts +++ b/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.handler.ts @@ -1,5 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type LoggerService, type PrismaService, NotFoundException } from '@modules/shared'; +import { type LoggerService, type PrismaService, DomainException, NotFoundException } from '@modules/shared'; import { ExportUserDataCommand } from './export-user-data.command'; export interface UserDataExport { @@ -31,27 +32,18 @@ export class ExportUserDataHandler implements ICommandHandler { - const user = await this.prisma.user.findUnique({ - where: { id: command.userId }, - select: { - id: true, email: true, phone: true, fullName: true, - role: true, kycStatus: true, createdAt: true, - }, - }); - - if (!user) throw new NotFoundException('User', command.userId); - - let agent: unknown | null; - let listings: unknown[]; - let payments: unknown[]; - let subscription: unknown | null; - let reviews: unknown[]; - let inquiries: unknown[]; - let savedSearches: unknown[]; - let transactions: unknown[]; - try { - [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] = + const user = await this.prisma.user.findUnique({ + where: { id: command.userId }, + select: { + id: true, email: true, phone: true, fullName: true, + role: true, kycStatus: true, createdAt: true, + }, + }); + + if (!user) throw new NotFoundException('User', command.userId); + + const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] = await Promise.all([ this.prisma.agent.findUnique({ where: { userId: command.userId } }), this.prisma.listing.findMany({ @@ -68,28 +60,29 @@ export class ExportUserDataHandler implements ICommandHandler { - const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); - if (!user) throw new NotFoundException('User', command.userId); - if (user.deletedAt) return { message: 'Tài khoản đã bị xóa' }; - try { + const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); + if (!user) throw new NotFoundException('User', command.userId); + if (user.deletedAt) return { message: 'Tài khoản đã bị xóa' }; + await this.anonymizeAndDelete(command.userId); - } catch (error) { - this.logger.error( - `Force delete transaction failed for user ${command.userId}: ${error instanceof Error ? error.message : error}`, - error instanceof Error ? error.stack : undefined, + + this.logger.log( + `User ${command.userId} force-deleted by admin ${command.adminId}: ${command.reason}`, 'ForceDeleteUserHandler', ); - throw error; + + return { message: 'Tài khoản đã bị xóa vĩnh viễn' }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to force delete user: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể xóa vĩnh viễn tài khoản người dùng'); } - - this.logger.log( - `User ${command.userId} force-deleted by admin ${command.adminId}: ${command.reason}`, - 'ForceDeleteUserHandler', - ); - - return { message: 'Tài khoản đã bị xóa vĩnh viễn' }; } private async anonymizeAndDelete(userId: string): Promise { diff --git a/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts b/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts index 45582a4..eefa4dc 100644 --- a/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts +++ b/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts @@ -1,5 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type LoggerService, UnauthorizedException } from '@modules/shared'; +import { type LoggerService, DomainException, UnauthorizedException } from '@modules/shared'; import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service'; import { LoginUserCommand } from './login-user.command'; @@ -18,12 +19,13 @@ export class LoginUserHandler implements ICommandHandler { role: command.role, }); } catch (error) { + if (error instanceof DomainException) throw error; this.logger.error( - `Token generation failed for user ${command.userId}: ${error instanceof Error ? error.message : error}`, + `Failed to login user: ${error instanceof Error ? error.message : error}`, error instanceof Error ? error.stack : undefined, - 'LoginUserHandler', + this.constructor.name, ); - throw new UnauthorizedException('Không thể tạo phiên đăng nhập, vui lòng thử lại'); + throw new InternalServerErrorException('Không thể tạo phiên đăng nhập, vui lòng thử lại'); } } } diff --git a/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.handler.ts b/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.handler.ts index 940f338..70094db 100644 --- a/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.handler.ts +++ b/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.handler.ts @@ -1,5 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type CommandBus, type ICommandHandler } from '@nestjs/cqrs'; -import { type LoggerService, type PrismaService } from '@modules/shared'; +import { type LoggerService, type PrismaService, DomainException } from '@modules/shared'; import { ForceDeleteUserCommand } from '../force-delete-user/force-delete-user.command'; import { ProcessScheduledDeletionsCommand } from './process-scheduled-deletions.command'; @@ -12,37 +13,47 @@ export class ProcessScheduledDeletionsHandler implements ICommandHandler { - const now = new Date(); + try { + const now = new Date(); - const usersToDelete = await this.prisma.user.findMany({ - where: { - deletionScheduledAt: { lte: now }, - deletedAt: null, - }, - select: { id: true }, - }); + const usersToDelete = await this.prisma.user.findMany({ + where: { + deletionScheduledAt: { lte: now }, + deletedAt: null, + }, + select: { id: true }, + }); - this.logger.log( - `Processing ${usersToDelete.length} scheduled deletions`, - 'ProcessScheduledDeletionsHandler', - ); + this.logger.log( + `Processing ${usersToDelete.length} scheduled deletions`, + 'ProcessScheduledDeletionsHandler', + ); - let processedCount = 0; - for (const user of usersToDelete) { - try { - await this.commandBus.execute( - new ForceDeleteUserCommand(user.id, 'SYSTEM', 'Scheduled GDPR deletion after 30-day grace period'), - ); - processedCount++; - } catch (error) { - this.logger.error( - `Failed to process deletion for user ${user.id}: ${error}`, - undefined, - 'ProcessScheduledDeletionsHandler', - ); + let processedCount = 0; + for (const user of usersToDelete) { + try { + await this.commandBus.execute( + new ForceDeleteUserCommand(user.id, 'SYSTEM', 'Scheduled GDPR deletion after 30-day grace period'), + ); + processedCount++; + } catch (error) { + this.logger.error( + `Failed to process deletion for user ${user.id}: ${error}`, + undefined, + 'ProcessScheduledDeletionsHandler', + ); + } } - } - return { processedCount }; + return { processedCount }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to process scheduled deletions: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể xử lý các yêu cầu xóa tài khoản theo lịch'); + } } } diff --git a/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts index 674f192..f361b7c 100644 --- a/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts +++ b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type LoggerService, UnauthorizedException } from '@modules/shared'; +import { type LoggerService, DomainException, UnauthorizedException } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service'; import { RefreshTokenCommand } from './refresh-token.command'; @@ -14,37 +14,47 @@ export class RefreshTokenHandler implements ICommandHandler ) {} async execute(command: RefreshTokenCommand): Promise { - let rotated: Awaited>; try { - rotated = await this.tokenService.rotateRefreshToken(command.refreshToken); + let rotated: Awaited>; + try { + rotated = await this.tokenService.rotateRefreshToken(command.refreshToken); + } catch (error) { + this.logger.error( + `Token rotation failed: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + 'RefreshTokenHandler', + ); + throw new UnauthorizedException('Không thể làm mới phiên đăng nhập'); + } + + if (!rotated) { + throw new UnauthorizedException('Refresh token không hợp lệ hoặc đã hết hạn'); + } + + const user = await this.userRepo.findById(rotated.userId); + if (!user || !user.isActive) { + throw new UnauthorizedException('Tài khoản không tồn tại hoặc đã bị vô hiệu hóa'); + } + + const accessToken = this.tokenService.generateAccessToken({ + sub: user.id, + phone: user.phone.value, + role: user.role, + }); + + return { + accessToken, + refreshToken: rotated.refreshToken, + expiresIn: 900, + }; } catch (error) { + if (error instanceof DomainException) throw error; this.logger.error( - `Token rotation failed: ${error instanceof Error ? error.message : error}`, + `Failed to refresh token: ${error instanceof Error ? error.message : error}`, error instanceof Error ? error.stack : undefined, - 'RefreshTokenHandler', + this.constructor.name, ); - throw new UnauthorizedException('Không thể làm mới phiên đăng nhập'); + throw new InternalServerErrorException('Không thể làm mới phiên đăng nhập'); } - - if (!rotated) { - throw new UnauthorizedException('Refresh token không hợp lệ hoặc đã hết hạn'); - } - - const user = await this.userRepo.findById(rotated.userId); - if (!user || !user.isActive) { - throw new UnauthorizedException('Tài khoản không tồn tại hoặc đã bị vô hiệu hóa'); - } - - const accessToken = this.tokenService.generateAccessToken({ - sub: user.id, - phone: user.phone.value, - role: user.role, - }); - - return { - accessToken, - refreshToken: rotated.refreshToken, - expiresIn: 900, - }; } } diff --git a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts index 1a81f1f..b2fca15 100644 --- a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts +++ b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts @@ -1,7 +1,7 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } 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, DomainException, LoggerService, ValidationException } from '@modules/shared'; import { UserEntity } from '../../../domain/entities/user.entity'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { Email } from '../../../domain/value-objects/email.vo'; @@ -16,62 +16,73 @@ export class RegisterUserHandler implements ICommandHandler @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, private readonly tokenService: TokenService, private readonly eventBus: EventBus, + private readonly logger: LoggerService, ) {} async execute(command: RegisterUserCommand): Promise { - // Validate phone - const phoneResult = Phone.create(command.phone); - if (phoneResult.isErr) { - throw new ValidationException(phoneResult.unwrapErr()); - } - const phone = phoneResult.unwrap(); - - // Check duplicate phone - const existingByPhone = await this.userRepo.findByPhone(phone.value); - if (existingByPhone) { - throw new ConflictException('Số điện thoại đã được đăng ký'); - } - - // Validate email if provided - let email: Email | undefined; - if (command.email) { - const emailResult = Email.create(command.email); - if (emailResult.isErr) { - throw new ValidationException(emailResult.unwrapErr()); + try { + // Validate phone + const phoneResult = Phone.create(command.phone); + if (phoneResult.isErr) { + throw new ValidationException(phoneResult.unwrapErr()); } - email = emailResult.unwrap(); + const phone = phoneResult.unwrap(); - const existingByEmail = await this.userRepo.findByEmail(email.value); - if (existingByEmail) { - throw new ConflictException('Email đã được đăng ký'); + // Check duplicate phone + const existingByPhone = await this.userRepo.findByPhone(phone.value); + if (existingByPhone) { + throw new ConflictException('Số điện thoại đã được đăng ký'); } + + // Validate email if provided + let email: Email | undefined; + if (command.email) { + const emailResult = Email.create(command.email); + if (emailResult.isErr) { + throw new ValidationException(emailResult.unwrapErr()); + } + email = emailResult.unwrap(); + + const existingByEmail = await this.userRepo.findByEmail(email.value); + if (existingByEmail) { + throw new ConflictException('Email đã được đăng ký'); + } + } + + // Hash password + const passwordResult = await HashedPassword.fromPlain(command.password); + if (passwordResult.isErr) { + throw new ValidationException(passwordResult.unwrapErr()); + } + const passwordHash = passwordResult.unwrap(); + + // Create user entity + const userId = createId(); + const user = UserEntity.createNew(userId, phone, command.fullName, passwordHash, email); + + // Persist + await this.userRepo.save(user); + + // Publish domain events + const events = user.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + // Generate tokens + return this.tokenService.generateTokenPair({ + sub: user.id, + phone: user.phone.value, + role: user.role, + }); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to register user: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể đăng ký tài khoản người dùng'); } - - // Hash password - const passwordResult = await HashedPassword.fromPlain(command.password); - if (passwordResult.isErr) { - throw new ValidationException(passwordResult.unwrapErr()); - } - const passwordHash = passwordResult.unwrap(); - - // Create user entity - const userId = createId(); - const user = UserEntity.createNew(userId, phone, command.fullName, passwordHash, email); - - // Persist - await this.userRepo.save(user); - - // Publish domain events - const events = user.clearDomainEvents(); - for (const event of events) { - this.eventBus.publish(event); - } - - // Generate tokens - return this.tokenService.generateTokenPair({ - sub: user.id, - phone: user.phone.value, - role: user.role, - }); } } diff --git a/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.handler.ts b/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.handler.ts index df69e9f..6469404 100644 --- a/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.handler.ts +++ b/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.handler.ts @@ -1,5 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type LoggerService, type PrismaService, NotFoundException, ValidationException } from '@modules/shared'; +import { type LoggerService, type PrismaService, DomainException, NotFoundException, ValidationException } from '@modules/shared'; import { RequestUserDeletionCommand } from './request-user-deletion.command'; const DELETION_GRACE_PERIOD_DAYS = 30; @@ -12,30 +13,40 @@ export class RequestUserDeletionHandler implements ICommandHandler { - const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); - if (!user) throw new NotFoundException('User', command.userId); - if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa'); - if (user.deletionScheduledAt) throw new ValidationException('Yêu cầu xóa đã tồn tại'); + try { + const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); + if (!user) throw new NotFoundException('User', command.userId); + if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa'); + if (user.deletionScheduledAt) throw new ValidationException('Yêu cầu xóa đã tồn tại'); - const scheduledAt = new Date(); - scheduledAt.setDate(scheduledAt.getDate() + DELETION_GRACE_PERIOD_DAYS); + const scheduledAt = new Date(); + scheduledAt.setDate(scheduledAt.getDate() + DELETION_GRACE_PERIOD_DAYS); - await this.prisma.user.update({ - where: { id: command.userId }, - data: { deletionScheduledAt: scheduledAt, isActive: false }, - }); + await this.prisma.user.update({ + where: { id: command.userId }, + data: { deletionScheduledAt: scheduledAt, isActive: false }, + }); - // Revoke all refresh tokens - await this.prisma.refreshToken.updateMany({ - where: { userId: command.userId, revokedAt: null }, - data: { revokedAt: new Date() }, - }); + // Revoke all refresh tokens + await this.prisma.refreshToken.updateMany({ + where: { userId: command.userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); - this.logger.log( - `User ${command.userId} deletion scheduled for ${scheduledAt.toISOString()}`, - 'RequestUserDeletionHandler', - ); + this.logger.log( + `User ${command.userId} deletion scheduled for ${scheduledAt.toISOString()}`, + 'RequestUserDeletionHandler', + ); - return { scheduledAt }; + return { scheduledAt }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to request user deletion: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể xử lý yêu cầu xóa tài khoản'); + } } } diff --git a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts index a1a10b4..236c1c7 100644 --- a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts +++ b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException, CacheService, CachePrefix } from '@modules/shared'; +import { DomainException, LoggerService, NotFoundException, CacheService, CachePrefix } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { VerifyKycCommand } from './verify-kyc.command'; @@ -9,17 +9,28 @@ export class VerifyKycHandler implements ICommandHandler { constructor( @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, private readonly cache: CacheService, + private readonly logger: LoggerService, ) {} async execute(command: VerifyKycCommand): Promise { - const user = await this.userRepo.findById(command.userId); - if (!user) { - throw new NotFoundException('Người dùng', command.userId); + try { + const user = await this.userRepo.findById(command.userId); + if (!user) { + throw new NotFoundException('Người dùng', command.userId); + } + + user.updateKycStatus(command.kycStatus, command.kycData); + await this.userRepo.update(user); + + await this.cache.invalidate(CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId)); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to verify KYC: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể xác minh KYC cho người dùng'); } - - user.updateKycStatus(command.kycStatus, command.kycData); - await this.userRepo.update(user); - - await this.cache.invalidate(CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId)); } } diff --git a/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts index d2aa37f..d96cc37 100644 --- a/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts +++ b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { type PrismaService } from '@modules/shared'; +import { type PrismaService, DomainException, LoggerService } from '@modules/shared'; import { GetAgentByUserIdQuery } from './get-agent-by-user-id.query'; export interface AgentDto { @@ -20,27 +20,40 @@ export interface AgentDto { @Injectable() @QueryHandler(GetAgentByUserIdQuery) export class GetAgentByUserIdHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} async execute(query: GetAgentByUserIdQuery): Promise { - const agent = await this.prisma.agent.findUnique({ - where: { userId: query.userId }, - }); + try { + const agent = await this.prisma.agent.findUnique({ + where: { userId: query.userId }, + }); - if (!agent) return null; + if (!agent) return null; - return { - id: agent.id, - userId: agent.userId, - licenseNumber: agent.licenseNumber, - agency: agent.agency, - qualityScore: agent.qualityScore, - totalDeals: agent.totalDeals, - responseTimeAvg: agent.responseTimeAvg, - bio: agent.bio, - serviceAreas: agent.serviceAreas, - isVerified: agent.isVerified, - createdAt: agent.createdAt, - }; + return { + id: agent.id, + userId: agent.userId, + licenseNumber: agent.licenseNumber, + agency: agent.agency, + qualityScore: agent.qualityScore, + totalDeals: agent.totalDeals, + responseTimeAvg: agent.responseTimeAvg, + bio: agent.bio, + serviceAreas: agent.serviceAreas, + isVerified: agent.isVerified, + createdAt: agent.createdAt, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get agent by user ID: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể lấy thông tin đại lý'); + } } } diff --git a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts index ef06a56..2ebbc03 100644 --- a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts +++ b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { DomainException, LoggerService, NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { GetProfileQuery } from './get-profile.query'; @@ -21,33 +21,44 @@ export class GetProfileHandler implements IQueryHandler { constructor( @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, private readonly cache: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: GetProfileQuery): Promise { - const cacheKey = CacheService.buildKey(CachePrefix.USER_PROFILE, query.userId); + try { + const cacheKey = CacheService.buildKey(CachePrefix.USER_PROFILE, query.userId); - return this.cache.getOrSet( - cacheKey, - async () => { - const user = await this.userRepo.findById(query.userId); - if (!user) { - throw new NotFoundException('Người dùng', query.userId); - } + return this.cache.getOrSet( + cacheKey, + async () => { + const user = await this.userRepo.findById(query.userId); + if (!user) { + throw new NotFoundException('Người dùng', query.userId); + } - return { - id: user.id, - email: user.email?.value ?? null, - phone: user.phone.value, - fullName: user.fullName, - avatarUrl: user.avatarUrl, - role: user.role, - kycStatus: user.kycStatus, - isActive: user.isActive, - createdAt: user.createdAt, - }; - }, - CacheTTL.USER_PROFILE, - 'user_profile', - ); + return { + id: user.id, + email: user.email?.value ?? null, + phone: user.phone.value, + fullName: user.fullName, + avatarUrl: user.avatarUrl, + role: user.role, + kycStatus: user.kycStatus, + isActive: user.isActive, + createdAt: user.createdAt, + }; + }, + CacheTTL.USER_PROFILE, + 'user_profile', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get user profile: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể lấy thông tin hồ sơ người dùng'); + } } } 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 9cd7448..86a6f12 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 } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { ValidationException, type CacheService, CachePrefix, type LoggerService } from '@modules/shared'; +import { DomainException, 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'; @@ -51,126 +51,136 @@ export class CreateListingHandler implements ICommandHandler { - // Validate value objects - const addressResult = Address.create(command.address, command.ward, command.district, command.city); - if (addressResult.isErr) throw new ValidationException(addressResult.unwrapErr()); - - const geoPointResult = GeoPoint.create(command.latitude, command.longitude); - if (geoPointResult.isErr) throw new ValidationException(geoPointResult.unwrapErr()); - - const priceResult = Price.create(command.priceVND); - if (priceResult.isErr) throw new ValidationException(priceResult.unwrapErr()); - - const address = addressResult.unwrap(); - const geoPoint = geoPointResult.unwrap(); - const price = priceResult.unwrap(); - - // Create property - const propertyId = createId(); - const property = PropertyEntity.createNew(propertyId, { - propertyType: command.propertyType, - title: command.title, - description: command.description, - address, - location: geoPoint, - areaM2: command.areaM2, - usableAreaM2: command.usableAreaM2 ?? null, - bedrooms: command.bedrooms ?? null, - bathrooms: command.bathrooms ?? null, - floors: command.floors ?? null, - floor: command.floor ?? null, - totalFloors: command.totalFloors ?? null, - direction: command.direction ?? null, - yearBuilt: command.yearBuilt ?? null, - legalStatus: command.legalStatus ?? null, - amenities: command.amenities ?? null, - nearbyPOIs: command.nearbyPOIs ?? null, - metroDistanceM: command.metroDistanceM ?? null, - projectName: command.projectName ?? null, - }); - - await this.propertyRepo.save(property); - - // Create listing - const listingId = createId(); - const listing = ListingEntity.createNew( - listingId, - propertyId, - command.sellerId, - command.transactionType, - price, - command.areaM2, - command.agentId, - command.rentPriceMonthly, - command.commissionPct, - ); - - await this.listingRepo.save(listing); - - // Publish domain events - const events = [...property.clearDomainEvents(), ...listing.clearDomainEvents()]; - for (const event of events) { - this.eventBus.publish(event); - } - - await Promise.all([ - this.cache.invalidateByPrefix(CachePrefix.SEARCH), - this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT), - this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT), - ]); - - // Duplicate detection — flag but never block creation - let duplicateWarnings: DuplicateWarning[] = []; try { - const candidates = await this.duplicateDetector.findDuplicates({ - excludePropertyId: propertyId, - latitude: command.latitude, - longitude: command.longitude, + // Validate value objects + const addressResult = Address.create(command.address, command.ward, command.district, command.city); + if (addressResult.isErr) throw new ValidationException(addressResult.unwrapErr()); + + const geoPointResult = GeoPoint.create(command.latitude, command.longitude); + if (geoPointResult.isErr) throw new ValidationException(geoPointResult.unwrapErr()); + + const priceResult = Price.create(command.priceVND); + if (priceResult.isErr) throw new ValidationException(priceResult.unwrapErr()); + + const address = addressResult.unwrap(); + const geoPoint = geoPointResult.unwrap(); + const price = priceResult.unwrap(); + + // Create property + const propertyId = createId(); + const property = PropertyEntity.createNew(propertyId, { + propertyType: command.propertyType, title: command.title, - propertyType: command.propertyType, - }); - - duplicateWarnings = candidates.map((c) => ({ - listingId: c.listingId, - propertyId: c.propertyId, - title: c.title, - address: c.address, - district: c.district, - distanceMeters: c.distanceMeters, - titleSimilarity: c.titleSimilarity, - })); - } catch { - this.logger.warn('Duplicate detection failed — listing created without warnings', 'CreateListingHandler'); - } - - // Price validation — flag but never block creation - let priceWarning: PriceWarning | undefined; - try { - const priceResult = await this.priceValidator.validate({ - priceVND: command.priceVND, + description: command.description, + address, + location: geoPoint, areaM2: command.areaM2, - propertyType: command.propertyType, - district: command.district, + usableAreaM2: command.usableAreaM2 ?? null, + bedrooms: command.bedrooms ?? null, + bathrooms: command.bathrooms ?? null, + floors: command.floors ?? null, + floor: command.floor ?? null, + totalFloors: command.totalFloors ?? null, + direction: command.direction ?? null, + yearBuilt: command.yearBuilt ?? null, + legalStatus: command.legalStatus ?? null, + amenities: command.amenities ?? null, + nearbyPOIs: command.nearbyPOIs ?? null, + metroDistanceM: command.metroDistanceM ?? null, + projectName: command.projectName ?? null, }); - if (priceResult.isSuspicious) { - priceWarning = { - pricePerM2: priceResult.pricePerM2, - minPricePerM2: priceResult.minPricePerM2, - maxPricePerM2: priceResult.maxPricePerM2, - reason: priceResult.reason!, - }; - } - } catch { - this.logger.warn('Price validation failed — listing created without price warning', 'CreateListingHandler'); - } + await this.propertyRepo.save(property); - return { - listingId, - propertyId, - status: listing.status, - duplicateWarnings, - priceWarning, - }; + // Create listing + const listingId = createId(); + const listing = ListingEntity.createNew( + listingId, + propertyId, + command.sellerId, + command.transactionType, + price, + command.areaM2, + command.agentId, + command.rentPriceMonthly, + command.commissionPct, + ); + + await this.listingRepo.save(listing); + + // Publish domain events + const events = [...property.clearDomainEvents(), ...listing.clearDomainEvents()]; + for (const event of events) { + this.eventBus.publish(event); + } + + await Promise.all([ + this.cache.invalidateByPrefix(CachePrefix.SEARCH), + this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT), + this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT), + ]); + + // Duplicate detection — flag but never block creation + let duplicateWarnings: DuplicateWarning[] = []; + try { + const candidates = await this.duplicateDetector.findDuplicates({ + excludePropertyId: propertyId, + latitude: command.latitude, + longitude: command.longitude, + title: command.title, + propertyType: command.propertyType, + }); + + duplicateWarnings = candidates.map((c) => ({ + listingId: c.listingId, + propertyId: c.propertyId, + title: c.title, + address: c.address, + district: c.district, + distanceMeters: c.distanceMeters, + titleSimilarity: c.titleSimilarity, + })); + } catch { + this.logger.warn('Duplicate detection failed — listing created without warnings', 'CreateListingHandler'); + } + + // Price validation — flag but never block creation + let priceWarning: PriceWarning | undefined; + try { + const priceResult = await this.priceValidator.validate({ + priceVND: command.priceVND, + areaM2: command.areaM2, + propertyType: command.propertyType, + district: command.district, + }); + + if (priceResult.isSuspicious) { + priceWarning = { + pricePerM2: priceResult.pricePerM2, + minPricePerM2: priceResult.minPricePerM2, + maxPricePerM2: priceResult.maxPricePerM2, + reason: priceResult.reason!, + }; + } + } catch { + this.logger.warn('Price validation failed — listing created without price warning', 'CreateListingHandler'); + } + + return { + listingId, + propertyId, + status: listing.status, + duplicateWarnings, + priceWarning, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to create listing: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tạo tin đăng bất động sản'); + } } } diff --git a/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.handler.ts b/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.handler.ts index 265e1be..ad2edab 100644 --- a/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/moderate-listing/moderate-listing.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException, CacheService, CachePrefix } from '@modules/shared'; +import { DomainException, NotFoundException, CacheService, CachePrefix, type LoggerService } from '@modules/shared'; import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI needs runtime reference import { ModerationService } from '../../../domain/services/moderation.service'; @@ -13,32 +13,43 @@ export class ModerateListingHandler implements ICommandHandler { - const listing = await this.listingRepo.findById(command.listingId); - if (!listing) { - throw new NotFoundException('Listing', command.listingId); + try { + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing', command.listingId); + } + + this.moderationService.applyModeration(listing, { + action: command.action, + moderationScore: command.moderationScore, + notes: command.notes, + }); + + await this.listingRepo.update(listing); + + const events = listing.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + await Promise.all([ + this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)), + this.cache.invalidateByPrefix(CachePrefix.SEARCH), + ]); + + return { status: listing.status }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to moderate listing ${command.listingId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể kiểm duyệt tin đăng'); } - - this.moderationService.applyModeration(listing, { - action: command.action, - moderationScore: command.moderationScore, - notes: command.notes, - }); - - await this.listingRepo.update(listing); - - const events = listing.clearDomainEvents(); - for (const event of events) { - this.eventBus.publish(event); - } - - await Promise.all([ - this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)), - this.cache.invalidateByPrefix(CachePrefix.SEARCH), - ]); - - return { status: listing.status }; } } diff --git a/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts index 73a5fd0..66d6859 100644 --- a/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts +++ b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException, CacheService, CachePrefix } from '@modules/shared'; +import { DomainException, NotFoundException, CacheService, CachePrefix, type LoggerService } from '@modules/shared'; import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI needs runtime reference import { ModerationService } from '../../../domain/services/moderation.service'; @@ -13,32 +13,43 @@ export class UpdateListingStatusHandler implements ICommandHandler { - const listing = await this.listingRepo.findById(command.listingId); - if (!listing) { - throw new NotFoundException('Listing', command.listingId); + try { + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing', command.listingId); + } + + this.moderationService.applyStatusTransition( + listing, + command.newStatus, + command.moderationNotes, + ); + + await this.listingRepo.update(listing); + + const events = listing.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + await Promise.all([ + this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)), + this.cache.invalidateByPrefix(CachePrefix.SEARCH), + ]); + + return { status: listing.status }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to update listing status ${command.listingId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể cập nhật trạng thái tin đăng'); } - - this.moderationService.applyStatusTransition( - listing, - command.newStatus, - command.moderationNotes, - ); - - await this.listingRepo.update(listing); - - const events = listing.clearDomainEvents(); - for (const event of events) { - this.eventBus.publish(event); - } - - await Promise.all([ - this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)), - this.cache.invalidateByPrefix(CachePrefix.SEARCH), - ]); - - return { status: listing.status }; } } diff --git a/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts b/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts index 9a757d7..a954583 100644 --- a/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts +++ b/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts @@ -1,7 +1,7 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { type LoggerService, NotFoundException, ValidationException } from '@modules/shared'; +import { DomainException, type LoggerService, NotFoundException, ValidationException } from '@modules/shared'; import { PropertyMediaEntity } from '../../../domain/entities/property-media.entity'; import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository'; import { MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '../../../infrastructure/services/media-storage.service'; @@ -18,47 +18,57 @@ export class UploadMediaHandler implements ICommandHandler { ) {} async execute(command: UploadMediaCommand): Promise<{ mediaId: string; url: string }> { - const property = await this.propertyRepo.findById(command.propertyId); - if (!property) { - throw new NotFoundException('Property', command.propertyId); - } - - const mediaCount = await this.propertyRepo.countMediaByPropertyId(command.propertyId); - if (mediaCount >= MAX_MEDIA_PER_PROPERTY) { - throw new ValidationException(`Tối đa ${MAX_MEDIA_PER_PROPERTY} ảnh/video cho mỗi bất động sản`); - } - - const mediaType = command.file.mimetype.startsWith('video/') ? 'video' as const : 'image' as const; - - let url: string; try { - url = await this.mediaStorage.upload( - command.file.buffer, - command.file.originalname, - command.file.mimetype, - `properties/${command.propertyId}`, + const property = await this.propertyRepo.findById(command.propertyId); + if (!property) { + throw new NotFoundException('Property', command.propertyId); + } + + const mediaCount = await this.propertyRepo.countMediaByPropertyId(command.propertyId); + if (mediaCount >= MAX_MEDIA_PER_PROPERTY) { + throw new ValidationException(`Tối đa ${MAX_MEDIA_PER_PROPERTY} ảnh/video cho mỗi bất động sản`); + } + + const mediaType = command.file.mimetype.startsWith('video/') ? 'video' as const : 'image' as const; + + let url: string; + try { + url = await this.mediaStorage.upload( + command.file.buffer, + command.file.originalname, + command.file.mimetype, + `properties/${command.propertyId}`, + ); + } catch (error) { + this.logger.error( + `Media upload failed for property ${command.propertyId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + 'UploadMediaHandler', + ); + throw new ValidationException('Tải lên media thất bại, vui lòng thử lại'); + } + + const mediaId = createId(); + const media = PropertyMediaEntity.createNew( + mediaId, + command.propertyId, + url, + mediaType, + mediaCount, // next order index + command.caption, ); + + await this.propertyRepo.addMedia(media); + + return { mediaId, url }; } catch (error) { + if (error instanceof DomainException) throw error; this.logger.error( - `Media upload failed for property ${command.propertyId}: ${error instanceof Error ? error.message : error}`, + `Failed to upload media for property ${command.propertyId}: ${error instanceof Error ? error.message : error}`, error instanceof Error ? error.stack : undefined, - 'UploadMediaHandler', + this.constructor.name, ); - throw new ValidationException('Tải lên media thất bại, vui lòng thử lại'); + throw new InternalServerErrorException('Không thể tải lên hình ảnh/video cho bất động sản'); } - - const mediaId = createId(); - const media = PropertyMediaEntity.createNew( - mediaId, - command.propertyId, - url, - mediaType, - mediaCount, // next order index - command.caption, - ); - - await this.propertyRepo.addMedia(media); - - return { mediaId, url }; } } diff --git a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts index 88cbc1c..1f29f5b 100644 --- a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts +++ b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { DomainException, NotFoundException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared'; import { type ListingDetailData } from '../../../domain/repositories/listing-read.dto'; import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; import { GetListingQuery } from './get-listing.query'; @@ -13,22 +13,33 @@ export class GetListingHandler implements IQueryHandler { constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, private readonly cache: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: GetListingQuery): Promise { - const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId); + try { + const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId); - return this.cache.getOrSet( - cacheKey, - async () => { - const result = await this.listingRepo.findByIdWithProperty(query.listingId); - if (!result) { - throw new NotFoundException('Listing', query.listingId); - } - return result; - }, - CacheTTL.LISTING_DETAIL, - 'listing', - ); + return this.cache.getOrSet( + cacheKey, + async () => { + const result = await this.listingRepo.findByIdWithProperty(query.listingId); + if (!result) { + throw new NotFoundException('Listing', query.listingId); + } + return result; + }, + CacheTTL.LISTING_DETAIL, + 'listing', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get listing ${query.listingId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể lấy thông tin tin đăng'); + } } } diff --git a/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts b/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts index 61a7fcd..2b108a5 100644 --- a/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts +++ b/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts @@ -1,5 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService } from '@modules/shared'; import { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto'; import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository'; import { GetPendingModerationQuery } from './get-pending-moderation.query'; @@ -8,9 +9,20 @@ import { GetPendingModerationQuery } from './get-pending-moderation.query'; export class GetPendingModerationHandler implements IQueryHandler { constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly logger: LoggerService, ) {} async execute(query: GetPendingModerationQuery): Promise> { - return this.listingRepo.findByStatus('PENDING_REVIEW', query.page, query.limit); + try { + return this.listingRepo.findByStatus('PENDING_REVIEW', query.page, query.limit); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get pending moderation listings: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể lấy danh sách tin đăng chờ kiểm duyệt'); + } } } diff --git a/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts index ca94548..8a184fc 100644 --- a/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts +++ b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { DomainException, CacheService, CachePrefix, CacheTTL, type LoggerService } from '@modules/shared'; import { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto'; import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository'; import { SearchListingsQuery } from './search-listings.query'; @@ -10,44 +10,55 @@ export class SearchListingsHandler implements IQueryHandler constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, private readonly cacheService: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: SearchListingsQuery): Promise> { - const cacheKey = CacheService.buildKey( - CachePrefix.SEARCH, - query.status, - query.transactionType, - query.propertyType, - query.city, - query.district, - query.minPrice?.toString(), - query.maxPrice?.toString(), - query.minArea?.toString(), - query.maxArea?.toString(), - query.bedrooms?.toString(), - String(query.page), - String(query.limit), - ); + try { + const cacheKey = CacheService.buildKey( + CachePrefix.SEARCH, + query.status, + query.transactionType, + query.propertyType, + query.city, + query.district, + query.minPrice?.toString(), + query.maxPrice?.toString(), + query.minArea?.toString(), + query.maxArea?.toString(), + query.bedrooms?.toString(), + String(query.page), + String(query.limit), + ); - return this.cacheService.getOrSet( - cacheKey, - async () => - this.listingRepo.search({ - status: query.status, - transactionType: query.transactionType, - propertyType: query.propertyType, - city: query.city, - district: query.district, - minPrice: query.minPrice, - maxPrice: query.maxPrice, - minArea: query.minArea, - maxArea: query.maxArea, - bedrooms: query.bedrooms, - page: query.page, - limit: query.limit, - }), - CacheTTL.SEARCH_RESULTS, - 'listing_search', - ); + return this.cacheService.getOrSet( + cacheKey, + async () => + this.listingRepo.search({ + status: query.status, + transactionType: query.transactionType, + propertyType: query.propertyType, + city: query.city, + district: query.district, + minPrice: query.minPrice, + maxPrice: query.maxPrice, + minArea: query.minArea, + maxArea: query.maxArea, + bedrooms: query.bedrooms, + page: query.page, + limit: query.limit, + }), + CacheTTL.SEARCH_RESULTS, + 'listing_search', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to search listings: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tìm kiếm tin đăng bất động sản'); + } } } diff --git a/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.handler.ts b/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.handler.ts index a901fed..d28d6f3 100644 --- a/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.handler.ts +++ b/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type EventBusService, type LoggerService } from '@modules/shared'; +import { DomainException, type EventBusService, type LoggerService } from '@modules/shared'; import { NotificationSentEvent } from '../../../domain/events/notification-sent.event'; import { NOTIFICATION_PREFERENCE_REPOSITORY, @@ -30,69 +30,79 @@ export class SendNotificationHandler implements ICommandHandler { - const { userId, channel, templateKey, templateData, recipientAddress } = command; - - // Check user preference - const isEnabled = await this.preferenceRepo.isEnabled(userId, channel, templateKey); - if (!isEnabled) { - this.logger.log( - `Notification skipped: user ${userId} disabled ${channel}/${templateKey}`, - 'SendNotificationHandler', - ); - return; - } - - // Render template - const rendered = this.templateService.render(templateKey, templateData); - - // Persist notification log - const notification = await this.notificationRepo.create({ - userId, - channel, - templateKey, - subject: rendered.subject, - body: rendered.body, - metadata: templateData, - }); - try { - switch (channel) { - case 'EMAIL': - await this.emailService.send({ - to: recipientAddress, - subject: rendered.subject, - html: rendered.body, - }); - break; + const { userId, channel, templateKey, templateData, recipientAddress } = command; - case 'PUSH': - await this.fcmService.send({ - token: recipientAddress, - title: rendered.subject, - body: rendered.body.replace(/<[^>]*>/g, ''), // Strip HTML for push - }); - break; - - case 'SMS': - case 'ZALO_OA': - // Placeholder — these channels will be implemented when providers are integrated - this.logger.warn( - `Channel ${channel} not yet implemented — notification logged but not sent`, - 'SendNotificationHandler', - ); - await this.notificationRepo.updateStatus(notification.id, 'PENDING'); - return; + // Check user preference + const isEnabled = await this.preferenceRepo.isEnabled(userId, channel, templateKey); + if (!isEnabled) { + this.logger.log( + `Notification skipped: user ${userId} disabled ${channel}/${templateKey}`, + 'SendNotificationHandler', + ); + return; } - await this.notificationRepo.updateStatus(notification.id, 'SENT'); - this.eventBus.publish(new NotificationSentEvent(notification.id, userId, channel, templateKey)); + // Render template + const rendered = this.templateService.render(templateKey, templateData); + + // Persist notification log + const notification = await this.notificationRepo.create({ + userId, + channel, + templateKey, + subject: rendered.subject, + body: rendered.body, + metadata: templateData, + }); + + try { + switch (channel) { + case 'EMAIL': + await this.emailService.send({ + to: recipientAddress, + subject: rendered.subject, + html: rendered.body, + }); + break; + + case 'PUSH': + await this.fcmService.send({ + token: recipientAddress, + title: rendered.subject, + body: rendered.body.replace(/<[^>]*>/g, ''), // Strip HTML for push + }); + break; + + case 'SMS': + case 'ZALO_OA': + // Placeholder — these channels will be implemented when providers are integrated + this.logger.warn( + `Channel ${channel} not yet implemented — notification logged but not sent`, + 'SendNotificationHandler', + ); + await this.notificationRepo.updateStatus(notification.id, 'PENDING'); + return; + } + + await this.notificationRepo.updateStatus(notification.id, 'SENT'); + this.eventBus.publish(new NotificationSentEvent(notification.id, userId, channel, templateKey)); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + await this.notificationRepo.updateStatus(notification.id, 'FAILED', errorMsg); + this.logger.error( + `Notification ${notification.id} failed on ${channel}: ${errorMsg}`, + 'SendNotificationHandler', + ); + } } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - await this.notificationRepo.updateStatus(notification.id, 'FAILED', errorMsg); + if (error instanceof DomainException) throw error; this.logger.error( - `Notification ${notification.id} failed on ${channel}: ${errorMsg}`, - 'SendNotificationHandler', + `Failed to send notification: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, ); + throw new InternalServerErrorException('Không thể gửi thông báo'); } } } 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 424bc19..0fcf0aa 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 } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { ConflictException, ValidationException, type LoggerService } from '@modules/shared'; +import { ConflictException, DomainException, ValidationException, type LoggerService } from '@modules/shared'; import { PaymentEntity } from '../../../domain/entities/payment.entity'; import { PAYMENT_REPOSITORY, @@ -32,73 +32,83 @@ export class CreatePaymentHandler implements ICommandHandler { - // Idempotency check - if (command.idempotencyKey) { - const existing = await this.paymentRepo.findByIdempotencyKey(command.idempotencyKey); - if (existing) { - if (existing.status === 'PENDING' || existing.status === 'PROCESSING') { - throw new ConflictException('Thanh toán với idempotency key này đã tồn tại'); - } - throw new ConflictException('Thanh toán đã được xử lý'); - } - } - - // Validate amount - const moneyResult = Money.create(command.amountVND); - if (moneyResult.isErr) { - throw new ValidationException(moneyResult.unwrapErr()); - } - - const money = moneyResult.unwrap(); - const paymentId = createId(); - - // Create domain entity - const payment = PaymentEntity.createNew( - paymentId, - command.userId, - command.provider, - command.type, - money, - command.transactionId, - command.idempotencyKey, - ); - - // Get payment gateway and create URL - const gateway = this.gatewayFactory.getGateway(command.provider); - let paymentUrl: string; - let providerTxId: string; try { - ({ paymentUrl, providerTxId } = await gateway.createPaymentUrl({ - orderId: paymentId, - amountVND: command.amountVND, - description: command.description, - returnUrl: command.returnUrl, - ipAddress: command.ipAddress, - })); - } catch (error) { - this.logger.error( - `Payment gateway ${command.provider} failed for order ${paymentId}: ${error instanceof Error ? error.message : error}`, - error instanceof Error ? error.stack : undefined, + // Idempotency check + if (command.idempotencyKey) { + const existing = await this.paymentRepo.findByIdempotencyKey(command.idempotencyKey); + if (existing) { + if (existing.status === 'PENDING' || existing.status === 'PROCESSING') { + throw new ConflictException('Thanh toán với idempotency key này đã tồn tại'); + } + throw new ConflictException('Thanh toán đã được xử lý'); + } + } + + // Validate amount + const moneyResult = Money.create(command.amountVND); + if (moneyResult.isErr) { + throw new ValidationException(moneyResult.unwrapErr()); + } + + const money = moneyResult.unwrap(); + const paymentId = createId(); + + // Create domain entity + const payment = PaymentEntity.createNew( + paymentId, + command.userId, + command.provider, + command.type, + money, + command.transactionId, + command.idempotencyKey, + ); + + // Get payment gateway and create URL + const gateway = this.gatewayFactory.getGateway(command.provider); + let paymentUrl: string; + let providerTxId: string; + try { + ({ paymentUrl, providerTxId } = await gateway.createPaymentUrl({ + orderId: paymentId, + amountVND: command.amountVND, + description: command.description, + returnUrl: command.returnUrl, + ipAddress: command.ipAddress, + })); + } catch (error) { + this.logger.error( + `Payment gateway ${command.provider} failed for order ${paymentId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + 'CreatePaymentHandler', + ); + throw new ValidationException('Không thể tạo liên kết thanh toán, vui lòng thử lại'); + } + + // Mark processing and save + payment.markProcessing(providerTxId); + await this.paymentRepo.save(payment); + + // Publish domain events + const events = payment.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Payment created: id=${paymentId}, provider=${command.provider}, amount=${command.amountVND}`, 'CreatePaymentHandler', ); - throw new ValidationException('Không thể tạo liên kết thanh toán, vui lòng thử lại'); + + return { paymentId, paymentUrl, providerTxId }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to create payment: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tạo thanh toán. Vui lòng thử lại sau'); } - - // Mark processing and save - payment.markProcessing(providerTxId); - await this.paymentRepo.save(payment); - - // Publish domain events - const events = payment.clearDomainEvents(); - for (const event of events) { - this.eventBus.publish(event); - } - - this.logger.log( - `Payment created: id=${paymentId}, provider=${command.provider}, amount=${command.amountVND}`, - 'CreatePaymentHandler', - ); - - return { paymentId, paymentUrl, providerTxId }; } } diff --git a/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.handler.ts b/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.handler.ts index adef243..ab48fa9 100644 --- a/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.handler.ts +++ b/apps/api/src/modules/payments/application/commands/handle-callback/handle-callback.handler.ts @@ -1,7 +1,7 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { type PaymentStatus } from '@prisma/client'; -import { NotFoundException, ValidationException, type LoggerService } from '@modules/shared'; +import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared'; import { PAYMENT_REPOSITORY, type IPaymentRepository, @@ -30,70 +30,80 @@ export class HandleCallbackHandler implements ICommandHandler { - const gateway = this.gatewayFactory.getGateway(command.provider); - const result = gateway.verifyCallback(command.callbackData); + try { + const gateway = this.gatewayFactory.getGateway(command.provider); + const result = gateway.verifyCallback(command.callbackData); - if (!result.isValid) { - this.logger.warn( - `Invalid callback signature for provider=${command.provider}`, - 'HandleCallbackHandler', - ); - throw new ValidationException('Chữ ký callback không hợp lệ'); - } - - // Atomically transition payment status to prevent race conditions - // on concurrent callbacks. Only PENDING/PROCESSING payments can be updated. - const targetStatus = result.isSuccess ? 'COMPLETED' : 'FAILED'; - const updated = await this.paymentRepo.updateIfStatus( - result.orderId, - ['PENDING', 'PROCESSING'], - { - status: targetStatus as PaymentStatus, - callbackData: result.rawData, - }, - ); - - if (!updated) { - // Either payment doesn't exist or is already in a terminal state - const existing = await this.paymentRepo.findById(result.orderId); - if (!existing) { - this.logger.warn(`Payment not found for orderId=${result.orderId}`, 'HandleCallbackHandler'); - throw new NotFoundException('Payment', result.orderId); + if (!result.isValid) { + this.logger.warn( + `Invalid callback signature for provider=${command.provider}`, + 'HandleCallbackHandler', + ); + throw new ValidationException('Chữ ký callback không hợp lệ'); + } + + // Atomically transition payment status to prevent race conditions + // on concurrent callbacks. Only PENDING/PROCESSING payments can be updated. + const targetStatus = result.isSuccess ? 'COMPLETED' : 'FAILED'; + const updated = await this.paymentRepo.updateIfStatus( + result.orderId, + ['PENDING', 'PROCESSING'], + { + status: targetStatus as PaymentStatus, + callbackData: result.rawData, + }, + ); + + if (!updated) { + // Either payment doesn't exist or is already in a terminal state + const existing = await this.paymentRepo.findById(result.orderId); + if (!existing) { + this.logger.warn(`Payment not found for orderId=${result.orderId}`, 'HandleCallbackHandler'); + throw new NotFoundException('Payment', result.orderId); + } + + // Already processed — return idempotent response + this.logger.log( + `Payment ${existing.id} already in terminal state: ${existing.status}`, + 'HandleCallbackHandler', + ); + return { + paymentId: existing.id, + status: existing.status, + isSuccess: existing.status === 'COMPLETED', + }; + } + + // Reconstruct domain entity and publish events + if (result.isSuccess) { + updated.emitCompleted(); + } else { + updated.emitFailed(); + } + + const events = updated.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); } - // Already processed — return idempotent response this.logger.log( - `Payment ${existing.id} already in terminal state: ${existing.status}`, + `Payment ${updated.id} callback processed: status=${updated.status}`, 'HandleCallbackHandler', ); + return { - paymentId: existing.id, - status: existing.status, - isSuccess: existing.status === 'COMPLETED', + paymentId: updated.id, + status: updated.status, + isSuccess: result.isSuccess, }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to handle payment callback: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể xử lý callback thanh toán. Vui lòng thử lại sau'); } - - // Reconstruct domain entity and publish events - if (result.isSuccess) { - updated.emitCompleted(); - } else { - updated.emitFailed(); - } - - const events = updated.clearDomainEvents(); - for (const event of events) { - this.eventBus.publish(event); - } - - this.logger.log( - `Payment ${updated.id} callback processed: status=${updated.status}`, - 'HandleCallbackHandler', - ); - - return { - paymentId: updated.id, - status: updated.status, - isSuccess: result.isSuccess, - }; } } diff --git a/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.handler.ts b/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.handler.ts index 7489400..613cc7c 100644 --- a/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.handler.ts +++ b/apps/api/src/modules/payments/application/commands/refund-payment/refund-payment.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException, ValidationException, type LoggerService } from '@modules/shared'; +import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared'; import { PAYMENT_REPOSITORY, type IPaymentRepository, @@ -28,43 +28,53 @@ export class RefundPaymentHandler implements ICommandHandler { - const payment = await this.paymentRepo.findById(command.paymentId); - if (!payment) { - throw new NotFoundException('Payment', command.paymentId); - } - - if (payment.status !== 'COMPLETED') { - throw new ValidationException('Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất'); - } - - if (!payment.providerTxId) { - throw new ValidationException('Không có mã giao dịch từ nhà cung cấp'); - } - - const gateway = this.gatewayFactory.getGateway(payment.provider); - const result = await gateway.refund({ - providerTxId: payment.providerTxId, - amountVND: payment.amount.value, - reason: command.reason, - }); - - if (result.success) { - const refundResult = payment.markRefunded(); - if (refundResult.isErr) { - throw refundResult.unwrapErr(); + try { + const payment = await this.paymentRepo.findById(command.paymentId); + if (!payment) { + throw new NotFoundException('Payment', command.paymentId); } - await this.paymentRepo.update(payment); + + if (payment.status !== 'COMPLETED') { + throw new ValidationException('Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất'); + } + + if (!payment.providerTxId) { + throw new ValidationException('Không có mã giao dịch từ nhà cung cấp'); + } + + const gateway = this.gatewayFactory.getGateway(payment.provider); + const result = await gateway.refund({ + providerTxId: payment.providerTxId, + amountVND: payment.amount.value, + reason: command.reason, + }); + + if (result.success) { + const refundResult = payment.markRefunded(); + if (refundResult.isErr) { + throw refundResult.unwrapErr(); + } + await this.paymentRepo.update(payment); + } + + this.logger.log( + `Refund ${result.success ? 'successful' : 'failed'} for payment ${command.paymentId}`, + 'RefundPaymentHandler', + ); + + return { + paymentId: command.paymentId, + refundTxId: result.refundTxId, + success: result.success, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to refund payment: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể hoàn tiền thanh toán. Vui lòng thử lại sau'); } - - this.logger.log( - `Refund ${result.success ? 'successful' : 'failed'} for payment ${command.paymentId}`, - 'RefundPaymentHandler', - ); - - return { - paymentId: command.paymentId, - refundTxId: result.refundTxId, - success: result.success, - }; } } diff --git a/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts index 33eca49..e9d1e19 100644 --- a/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts +++ b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { NotFoundException, ForbiddenException } from '@modules/shared'; +import { DomainException, ForbiddenException, LoggerService, NotFoundException } from '@modules/shared'; import { PAYMENT_REPOSITORY, type IPaymentRepository, @@ -23,27 +23,38 @@ export class GetPaymentStatusHandler implements IQueryHandler { - const payment = await this.paymentRepo.findById(query.paymentId); - if (!payment) { - throw new NotFoundException('Payment', query.paymentId); - } + try { + const payment = await this.paymentRepo.findById(query.paymentId); + if (!payment) { + throw new NotFoundException('Payment', query.paymentId); + } - if (payment.userId !== query.userId) { - throw new ForbiddenException('Bạn không có quyền xem thanh toán này'); - } + if (payment.userId !== query.userId) { + throw new ForbiddenException('Bạn không có quyền xem thanh toán này'); + } - return { - id: payment.id, - provider: payment.provider, - type: payment.type, - amountVND: payment.amount.value.toString(), - status: payment.status, - providerTxId: payment.providerTxId, - createdAt: payment.createdAt, - updatedAt: payment.updatedAt, - }; + return { + id: payment.id, + provider: payment.provider, + type: payment.type, + amountVND: payment.amount.value.toString(), + status: payment.status, + providerTxId: payment.providerTxId, + createdAt: payment.createdAt, + updatedAt: payment.updatedAt, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get payment status: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể truy vấn trạng thái thanh toán. Vui lòng thử lại sau'); + } } } diff --git a/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts b/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts index cf4bf25..dc23d64 100644 --- a/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts +++ b/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts @@ -1,5 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { DomainException, LoggerService } from '@modules/shared'; import { PAYMENT_REPOSITORY, type IPaymentRepository, @@ -28,31 +29,42 @@ export class ListTransactionsHandler implements IQueryHandler { - const limit = Math.min(query.limit ?? 20, 100); - const offset = query.offset ?? 0; + try { + const limit = Math.min(query.limit ?? 20, 100); + const offset = query.offset ?? 0; - const { items, total } = await this.paymentRepo.findByUserId(query.userId, { - status: query.status, - limit, - offset, - }); + const { items, total } = await this.paymentRepo.findByUserId(query.userId, { + status: query.status, + limit, + offset, + }); - return { - items: items.map((payment) => ({ - id: payment.id, - provider: payment.provider, - type: payment.type, - amountVND: payment.amount.value.toString(), - status: payment.status, - providerTxId: payment.providerTxId, - createdAt: payment.createdAt, - })), - total, - limit, - offset, - }; + return { + items: items.map((payment) => ({ + id: payment.id, + provider: payment.provider, + type: payment.type, + amountVND: payment.amount.value.toString(), + status: payment.status, + providerTxId: payment.providerTxId, + createdAt: payment.createdAt, + })), + total, + limit, + offset, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to list transactions: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể truy vấn danh sách giao dịch. Vui lòng thử lại sau'); + } } } diff --git a/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.handler.ts b/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.handler.ts index 1c7a382..1d22b0c 100644 --- a/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.handler.ts +++ b/apps/api/src/modules/search/application/commands/create-saved-search/create-saved-search.handler.ts @@ -1,7 +1,8 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type CommandBus, type ICommandHandler, type QueryBus } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; import { type SavedSearch, type Prisma } from '@prisma/client'; -import { ValidationException, type PrismaService, type LoggerService } from '@modules/shared'; +import { DomainException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared'; import { CheckQuotaQuery, type QuotaCheckResult, MeterUsageCommand } from '@modules/subscriptions'; import { CreateSavedSearchCommand } from './create-saved-search.command'; @@ -23,57 +24,67 @@ export class CreateSavedSearchHandler implements ICommandHandler { - // Validate name - if (!command.name || command.name.trim().length === 0) { - throw new ValidationException('Tên tìm kiếm không được để trống'); - } - - if (command.name.trim().length > 100) { - throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự'); - } - - // Check quota - const quotaResult: QuotaCheckResult = await this.queryBus.execute( - new CheckQuotaQuery(command.userId, 'searches_saved'), - ); - - if (!quotaResult.allowed) { - throw new ValidationException( - `Bạn đã đạt giới hạn ${quotaResult.limit} tìm kiếm đã lưu. Vui lòng nâng cấp gói để tiếp tục.`, - ); - } - - const id = createId(); - const savedSearch: SavedSearch = await this.prisma.savedSearch.create({ - data: { - id, - userId: command.userId, - name: command.name.trim(), - filters: command.filters as Prisma.InputJsonValue, - alertEnabled: command.alertEnabled, - }, - }); - - // Best-effort usage metering try { - await this.commandBus.execute( - new MeterUsageCommand(command.userId, 'searches_saved', 1), + // Validate name + if (!command.name || command.name.trim().length === 0) { + throw new ValidationException('Tên tìm kiếm không được để trống'); + } + + if (command.name.trim().length > 100) { + throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự'); + } + + // Check quota + const quotaResult: QuotaCheckResult = await this.queryBus.execute( + new CheckQuotaQuery(command.userId, 'searches_saved'), ); - } catch (err) { - this.logger.warn( - `Usage metering failed for saved search: ${err instanceof Error ? err.message : String(err)}`, - 'CreateSavedSearchHandler', + + if (!quotaResult.allowed) { + throw new ValidationException( + `Bạn đã đạt giới hạn ${quotaResult.limit} tìm kiếm đã lưu. Vui lòng nâng cấp gói để tiếp tục.`, + ); + } + + const id = createId(); + const savedSearch: SavedSearch = await this.prisma.savedSearch.create({ + data: { + id, + userId: command.userId, + name: command.name.trim(), + filters: command.filters as Prisma.InputJsonValue, + alertEnabled: command.alertEnabled, + }, + }); + + // Best-effort usage metering + try { + await this.commandBus.execute( + new MeterUsageCommand(command.userId, 'searches_saved', 1), + ); + } catch (err) { + this.logger.warn( + `Usage metering failed for saved search: ${err instanceof Error ? err.message : String(err)}`, + 'CreateSavedSearchHandler', + ); + } + + this.logger.log(`Saved search created: id=${id}, user=${command.userId}`, 'CreateSavedSearchHandler'); + + return { + id: savedSearch.id, + name: savedSearch.name, + filters: savedSearch.filters, + alertEnabled: savedSearch.alertEnabled, + createdAt: savedSearch.createdAt, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to create saved search: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, ); + throw new InternalServerErrorException('Không thể tạo tìm kiếm đã lưu'); } - - this.logger.log(`Saved search created: id=${id}, user=${command.userId}`, 'CreateSavedSearchHandler'); - - return { - id: savedSearch.id, - name: savedSearch.name, - filters: savedSearch.filters, - alertEnabled: savedSearch.alertEnabled, - createdAt: savedSearch.createdAt, - }; } } diff --git a/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.handler.ts b/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.handler.ts index a442484..60d31e2 100644 --- a/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.handler.ts +++ b/apps/api/src/modules/search/application/commands/delete-saved-search/delete-saved-search.handler.ts @@ -1,5 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared'; +import { DomainException, ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared'; import { DeleteSavedSearchCommand } from './delete-saved-search.command'; @CommandHandler(DeleteSavedSearchCommand) @@ -10,22 +11,32 @@ export class DeleteSavedSearchHandler implements ICommandHandler { - const savedSearch = await this.prisma.savedSearch.findUnique({ - where: { id: command.id }, - }); + try { + const savedSearch = await this.prisma.savedSearch.findUnique({ + where: { id: command.id }, + }); - if (!savedSearch) { - throw new NotFoundException('SavedSearch', command.id); + if (!savedSearch) { + throw new NotFoundException('SavedSearch', command.id); + } + + if (savedSearch.userId !== command.userId) { + throw new ForbiddenException('Bạn không có quyền xóa tìm kiếm này'); + } + + await this.prisma.savedSearch.delete({ where: { id: command.id } }); + + this.logger.log(`Saved search deleted: id=${command.id}, user=${command.userId}`, 'DeleteSavedSearchHandler'); + + return { deleted: true }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to delete saved search: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể xóa tìm kiếm đã lưu'); } - - if (savedSearch.userId !== command.userId) { - throw new ForbiddenException('Bạn không có quyền xóa tìm kiếm này'); - } - - await this.prisma.savedSearch.delete({ where: { id: command.id } }); - - this.logger.log(`Saved search deleted: id=${command.id}, user=${command.userId}`, 'DeleteSavedSearchHandler'); - - return { deleted: true }; } } diff --git a/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.handler.ts b/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.handler.ts index 4c5d003..fed661c 100644 --- a/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.handler.ts +++ b/apps/api/src/modules/search/application/commands/reindex-all/reindex-all.handler.ts @@ -1,4 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService } from '@modules/shared'; import { type ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service'; import { ReindexAllCommand } from './reindex-all.command'; @@ -9,9 +11,22 @@ export interface ReindexResult { @CommandHandler(ReindexAllCommand) export class ReindexAllHandler implements ICommandHandler { - constructor(private readonly indexer: ListingIndexerService) {} + constructor( + private readonly indexer: ListingIndexerService, + private readonly logger: LoggerService, + ) {} async execute(): Promise { - return this.indexer.reindexAll(); + try { + return this.indexer.reindexAll(); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to reindex all listings: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể đánh chỉ mục lại toàn bộ danh sách'); + } } } diff --git a/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.handler.ts b/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.handler.ts index f25d398..8131764 100644 --- a/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.handler.ts +++ b/apps/api/src/modules/search/application/commands/sync-listing/sync-listing.handler.ts @@ -1,12 +1,27 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService } from '@modules/shared'; import { type ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service'; import { SyncListingCommand } from './sync-listing.command'; @CommandHandler(SyncListingCommand) export class SyncListingHandler implements ICommandHandler { - constructor(private readonly indexer: ListingIndexerService) {} + constructor( + private readonly indexer: ListingIndexerService, + private readonly logger: LoggerService, + ) {} async execute(command: SyncListingCommand): Promise { - await this.indexer.indexListing(command.listingId); + try { + await this.indexer.indexListing(command.listingId); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to sync listing ${command.listingId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể đồng bộ tin đăng vào chỉ mục tìm kiếm'); + } } } diff --git a/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.handler.ts b/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.handler.ts index 8d17653..efeb9b4 100644 --- a/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.handler.ts +++ b/apps/api/src/modules/search/application/commands/update-saved-search/update-saved-search.handler.ts @@ -1,6 +1,7 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; import { type Prisma } from '@prisma/client'; -import { ForbiddenException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared'; +import { DomainException, ForbiddenException, NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared'; import { UpdateSavedSearchCommand } from './update-saved-search.command'; export interface UpdateSavedSearchResult { @@ -19,43 +20,53 @@ export class UpdateSavedSearchHandler implements ICommandHandler { - const savedSearch = await this.prisma.savedSearch.findUnique({ - where: { id: command.id }, - }); + try { + const savedSearch = await this.prisma.savedSearch.findUnique({ + where: { id: command.id }, + }); - if (!savedSearch) { - throw new NotFoundException('SavedSearch', command.id); + if (!savedSearch) { + throw new NotFoundException('SavedSearch', command.id); + } + + if (savedSearch.userId !== command.userId) { + throw new ForbiddenException('Bạn không có quyền cập nhật tìm kiếm này'); + } + + if (command.name !== undefined && command.name.trim().length === 0) { + throw new ValidationException('Tên tìm kiếm không được để trống'); + } + + if (command.name !== undefined && command.name.trim().length > 100) { + throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự'); + } + + const updated = await this.prisma.savedSearch.update({ + where: { id: command.id }, + data: { + ...(command.name !== undefined && { name: command.name.trim() }), + ...(command.filters !== undefined && { filters: command.filters as Prisma.InputJsonValue }), + ...(command.alertEnabled !== undefined && { alertEnabled: command.alertEnabled }), + }, + }); + + this.logger.log(`Saved search updated: id=${command.id}, user=${command.userId}`, 'UpdateSavedSearchHandler'); + + return { + id: updated.id, + name: updated.name, + filters: updated.filters, + alertEnabled: updated.alertEnabled, + createdAt: updated.createdAt, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to update saved search: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể cập nhật tìm kiếm đã lưu'); } - - if (savedSearch.userId !== command.userId) { - throw new ForbiddenException('Bạn không có quyền cập nhật tìm kiếm này'); - } - - if (command.name !== undefined && command.name.trim().length === 0) { - throw new ValidationException('Tên tìm kiếm không được để trống'); - } - - if (command.name !== undefined && command.name.trim().length > 100) { - throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự'); - } - - const updated = await this.prisma.savedSearch.update({ - where: { id: command.id }, - data: { - ...(command.name !== undefined && { name: command.name.trim() }), - ...(command.filters !== undefined && { filters: command.filters as Prisma.InputJsonValue }), - ...(command.alertEnabled !== undefined && { alertEnabled: command.alertEnabled }), - }, - }); - - this.logger.log(`Saved search updated: id=${command.id}, user=${command.userId}`, 'UpdateSavedSearchHandler'); - - return { - id: updated.id, - name: updated.name, - filters: updated.filters, - alertEnabled: updated.alertEnabled, - createdAt: updated.createdAt, - }; } } diff --git a/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts b/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts index f679be1..0f5b8f1 100644 --- a/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts +++ b/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared'; import { SEARCH_REPOSITORY, type ISearchRepository, @@ -13,52 +13,63 @@ export class GeoSearchHandler implements IQueryHandler { constructor( @Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository, private readonly cache: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: GeoSearchQuery): Promise { - const cacheKey = CacheService.buildKey( - CachePrefix.GEO_SEARCH, - `${query.lat}_${query.lng}_${query.radiusKm}`, - query.propertyType, - query.transactionType, - query.priceMin, - query.priceMax, - query.sortBy, - query.page, - query.perPage, - ); + try { + const cacheKey = CacheService.buildKey( + CachePrefix.GEO_SEARCH, + `${query.lat}_${query.lng}_${query.radiusKm}`, + query.propertyType, + query.transactionType, + query.priceMin, + query.priceMax, + query.sortBy, + query.page, + query.perPage, + ); - return this.cache.getOrSet( - cacheKey, - async () => { - const filters: string[] = ['status:=ACTIVE']; + return this.cache.getOrSet( + cacheKey, + async () => { + const filters: string[] = ['status:=ACTIVE']; - if (query.propertyType) { - filters.push(`propertyType:=${query.propertyType}`); - } - if (query.transactionType) { - filters.push(`transactionType:=${query.transactionType}`); - } - if (query.priceMin !== undefined && query.priceMax !== undefined) { - filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`); - } else if (query.priceMin !== undefined) { - filters.push(`priceVND:>=${query.priceMin}`); - } else if (query.priceMax !== undefined) { - filters.push(`priceVND:<=${query.priceMax}`); - } + if (query.propertyType) { + filters.push(`propertyType:=${query.propertyType}`); + } + if (query.transactionType) { + filters.push(`transactionType:=${query.transactionType}`); + } + if (query.priceMin !== undefined && query.priceMax !== undefined) { + filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`); + } else if (query.priceMin !== undefined) { + filters.push(`priceVND:>=${query.priceMin}`); + } else if (query.priceMax !== undefined) { + filters.push(`priceVND:<=${query.priceMax}`); + } - return this.searchRepo.search({ - query: '*', - filterBy: filters.join(' && '), - sortBy: query.sortBy, - page: query.page, - perPage: query.perPage, - geoPoint: { lat: query.lat, lng: query.lng }, - geoRadiusKm: Math.min(query.radiusKm, 100), - }); - }, - CacheTTL.SEARCH_RESULTS, - 'geo_search', - ); + return this.searchRepo.search({ + query: '*', + filterBy: filters.join(' && '), + sortBy: query.sortBy, + page: query.page, + perPage: query.perPage, + geoPoint: { lat: query.lat, lng: query.lng }, + geoRadiusKm: Math.min(query.radiusKm, 100), + }); + }, + CacheTTL.SEARCH_RESULTS, + 'geo_search', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to execute geo search: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể thực hiện tìm kiếm theo vị trí địa lý'); + } } } diff --git a/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.handler.ts b/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.handler.ts index 633f1a2..7683e3a 100644 --- a/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.handler.ts +++ b/apps/api/src/modules/search/application/queries/get-saved-search/get-saved-search.handler.ts @@ -1,5 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared'; +import { DomainException, ForbiddenException, NotFoundException, type LoggerService, type PrismaService } from '@modules/shared'; import { GetSavedSearchQuery } from './get-saved-search.query'; export interface SavedSearchDetail { @@ -15,28 +16,39 @@ export interface SavedSearchDetail { export class GetSavedSearchHandler implements IQueryHandler { constructor( private readonly prisma: PrismaService, + private readonly logger: LoggerService, ) {} async execute(query: GetSavedSearchQuery): Promise { - const savedSearch = await this.prisma.savedSearch.findUnique({ - where: { id: query.id }, - }); + try { + const savedSearch = await this.prisma.savedSearch.findUnique({ + where: { id: query.id }, + }); - if (!savedSearch) { - throw new NotFoundException('SavedSearch', query.id); + if (!savedSearch) { + throw new NotFoundException('SavedSearch', query.id); + } + + if (savedSearch.userId !== query.userId) { + throw new ForbiddenException('Bạn không có quyền xem tìm kiếm này'); + } + + return { + id: savedSearch.id, + name: savedSearch.name, + filters: savedSearch.filters, + alertEnabled: savedSearch.alertEnabled, + lastAlertAt: savedSearch.lastAlertAt, + createdAt: savedSearch.createdAt, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get saved search: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể lấy thông tin tìm kiếm đã lưu'); } - - if (savedSearch.userId !== query.userId) { - throw new ForbiddenException('Bạn không có quyền xem tìm kiếm này'); - } - - return { - id: savedSearch.id, - name: savedSearch.name, - filters: savedSearch.filters, - alertEnabled: savedSearch.alertEnabled, - lastAlertAt: savedSearch.lastAlertAt, - createdAt: savedSearch.createdAt, - }; } } diff --git a/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.handler.ts b/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.handler.ts index d9a60ad..3257bb2 100644 --- a/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.handler.ts +++ b/apps/api/src/modules/search/application/queries/get-saved-searches/get-saved-searches.handler.ts @@ -1,5 +1,6 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { type PrismaService } from '@modules/shared'; +import { DomainException, type LoggerService, type PrismaService } from '@modules/shared'; import { GetSavedSearchesQuery } from './get-saved-searches.query'; export interface SavedSearchItem { @@ -22,35 +23,46 @@ export interface SavedSearchListResult { export class GetSavedSearchesHandler implements IQueryHandler { constructor( private readonly prisma: PrismaService, + private readonly logger: LoggerService, ) {} async execute(query: GetSavedSearchesQuery): Promise { - const skip = (query.page - 1) * query.limit; + try { + const skip = (query.page - 1) * query.limit; - const [data, total] = await Promise.all([ - this.prisma.savedSearch.findMany({ - where: { userId: query.userId }, - orderBy: { createdAt: 'desc' }, - skip, - take: query.limit, - }), - this.prisma.savedSearch.count({ - where: { userId: query.userId }, - }), - ]); + const [data, total] = await Promise.all([ + this.prisma.savedSearch.findMany({ + where: { userId: query.userId }, + orderBy: { createdAt: 'desc' }, + skip, + take: query.limit, + }), + this.prisma.savedSearch.count({ + where: { userId: query.userId }, + }), + ]); - return { - data: data.map((s) => ({ - id: s.id, - name: s.name, - filters: s.filters, - alertEnabled: s.alertEnabled, - lastAlertAt: s.lastAlertAt, - createdAt: s.createdAt, - })), - total, - page: query.page, - limit: query.limit, - }; + return { + data: data.map((s) => ({ + id: s.id, + name: s.name, + filters: s.filters, + alertEnabled: s.alertEnabled, + lastAlertAt: s.lastAlertAt, + createdAt: s.createdAt, + })), + total, + page: query.page, + limit: query.limit, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get saved searches: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể lấy danh sách tìm kiếm đã lưu'); + } } } diff --git a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts index ce982fa..fde6ab8 100644 --- a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts +++ b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared'; import { SEARCH_REPOSITORY, type ISearchRepository, @@ -13,71 +13,82 @@ export class SearchPropertiesHandler implements IQueryHandler { - const filters: string[] = ['status:=ACTIVE']; + try { + const filters: string[] = ['status:=ACTIVE']; - if (query.propertyType) { - filters.push(`propertyType:=${query.propertyType}`); - } - if (query.transactionType) { - filters.push(`transactionType:=${query.transactionType}`); - } - if (query.priceMin !== undefined && query.priceMax !== undefined) { - filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`); - } else if (query.priceMin !== undefined) { - filters.push(`priceVND:>=${query.priceMin}`); - } else if (query.priceMax !== undefined) { - filters.push(`priceVND:<=${query.priceMax}`); - } - if (query.areaMin !== undefined && query.areaMax !== undefined) { - filters.push(`areaM2:[${query.areaMin}..${query.areaMax}]`); - } else if (query.areaMin !== undefined) { - filters.push(`areaM2:>=${query.areaMin}`); - } else if (query.areaMax !== undefined) { - filters.push(`areaM2:<=${query.areaMax}`); - } - if (query.bedrooms !== undefined) { - filters.push(`bedrooms:>=${query.bedrooms}`); - } - if (query.district) { - filters.push(`district:=${query.district}`); - } - if (query.city) { - filters.push(`city:=${query.city}`); - } + if (query.propertyType) { + filters.push(`propertyType:=${query.propertyType}`); + } + if (query.transactionType) { + filters.push(`transactionType:=${query.transactionType}`); + } + if (query.priceMin !== undefined && query.priceMax !== undefined) { + filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`); + } else if (query.priceMin !== undefined) { + filters.push(`priceVND:>=${query.priceMin}`); + } else if (query.priceMax !== undefined) { + filters.push(`priceVND:<=${query.priceMax}`); + } + if (query.areaMin !== undefined && query.areaMax !== undefined) { + filters.push(`areaM2:[${query.areaMin}..${query.areaMax}]`); + } else if (query.areaMin !== undefined) { + filters.push(`areaM2:>=${query.areaMin}`); + } else if (query.areaMax !== undefined) { + filters.push(`areaM2:<=${query.areaMax}`); + } + if (query.bedrooms !== undefined) { + filters.push(`bedrooms:>=${query.bedrooms}`); + } + if (query.district) { + filters.push(`district:=${query.district}`); + } + if (query.city) { + filters.push(`city:=${query.city}`); + } - const searchParams = { - query: query.query, - filterBy: filters.join(' && '), - sortBy: query.sortBy, - page: query.page, - perPage: query.perPage, - }; + const searchParams = { + query: query.query, + filterBy: filters.join(' && '), + sortBy: query.sortBy, + page: query.page, + perPage: query.perPage, + }; - const cacheKey = CacheService.buildKey( - CachePrefix.SEARCH, - query.query ?? '*', - query.propertyType, - query.transactionType, - query.district, - query.city, - query.page, - query.perPage, - query.priceMin, - query.priceMax, - query.areaMin, - query.areaMax, - query.bedrooms, - query.sortBy, - ); + const cacheKey = CacheService.buildKey( + CachePrefix.SEARCH, + query.query ?? '*', + query.propertyType, + query.transactionType, + query.district, + query.city, + query.page, + query.perPage, + query.priceMin, + query.priceMax, + query.areaMin, + query.areaMax, + query.bedrooms, + query.sortBy, + ); - return this.cache.getOrSet( - cacheKey, - () => this.searchRepo.search(searchParams), - CacheTTL.SEARCH_RESULTS, - 'search', - ); + return this.cache.getOrSet( + cacheKey, + () => this.searchRepo.search(searchParams), + CacheTTL.SEARCH_RESULTS, + 'search', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to search properties: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tìm kiếm bất động sản'); + } } } 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 301cd8c..ab34a08 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 } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException, ValidationException, type LoggerService } from '@modules/shared'; +import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared'; import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository, @@ -23,36 +23,46 @@ export class CancelSubscriptionHandler implements ICommandHandler { - const subscription = await this.subscriptionRepo.findByUserId(command.userId); - if (!subscription) { - throw new NotFoundException('Subscription', command.userId); + try { + const subscription = await this.subscriptionRepo.findByUserId(command.userId); + if (!subscription) { + throw new NotFoundException('Subscription', command.userId); + } + + if (subscription.status === 'CANCELLED') { + throw new ValidationException('Subscription đã bị hủy trước đó'); + } + + const cancelResult = subscription.cancel(); + if (cancelResult.isErr) { + throw cancelResult.unwrapErr(); + } + await this.subscriptionRepo.update(subscription); + + // Publish domain events + const events = subscription.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Subscription cancelled: id=${subscription.id}, user=${command.userId}, reason=${command.reason ?? 'N/A'}`, + 'CancelSubscriptionHandler', + ); + + return { + subscriptionId: subscription.id, + status: 'CANCELLED', + cancelledAt: subscription.cancelledAt!, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to hủy subscription: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể hủy gói đăng ký. Vui lòng thử lại sau.'); } - - if (subscription.status === 'CANCELLED') { - throw new ValidationException('Subscription đã bị hủy trước đó'); - } - - const cancelResult = subscription.cancel(); - if (cancelResult.isErr) { - throw cancelResult.unwrapErr(); - } - await this.subscriptionRepo.update(subscription); - - // Publish domain events - const events = subscription.clearDomainEvents(); - for (const event of events) { - this.eventBus.publish(event); - } - - this.logger.log( - `Subscription cancelled: id=${subscription.id}, user=${command.userId}, reason=${command.reason ?? 'N/A'}`, - 'CancelSubscriptionHandler', - ); - - return { - subscriptionId: subscription.id, - status: 'CANCELLED', - cancelledAt: subscription.cancelledAt!, - }; } } diff --git a/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts index 36503b3..801fb92 100644 --- a/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts @@ -1,7 +1,7 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { NotFoundException, ConflictException, type PrismaService, type LoggerService } from '@modules/shared'; +import { DomainException, NotFoundException, ConflictException, type PrismaService, type LoggerService } from '@modules/shared'; import { SubscriptionEntity } from '../../../domain/entities/subscription.entity'; import { SUBSCRIPTION_REPOSITORY, @@ -28,58 +28,68 @@ export class CreateSubscriptionHandler implements ICommandHandler { - // Check if user already has an active subscription - const existing = await this.subscriptionRepo.findByUserId(command.userId); - if (existing && (existing.status === 'ACTIVE' || existing.status === 'PAST_DUE')) { - throw new ConflictException('Người dùng đã có subscription đang hoạt động'); + try { + // Check if user already has an active subscription + const existing = await this.subscriptionRepo.findByUserId(command.userId); + if (existing && (existing.status === 'ACTIVE' || existing.status === 'PAST_DUE')) { + throw new ConflictException('Người dùng đã có subscription đang hoạt động'); + } + + // Fetch plan + const plan = await this.prisma.plan.findFirst({ + where: { tier: command.planTier, isActive: true }, + }); + if (!plan) { + throw new NotFoundException('Plan', command.planTier); + } + + // Calculate period + const now = new Date(); + const periodEnd = new Date(now); + if (command.billingCycle === 'yearly') { + periodEnd.setFullYear(periodEnd.getFullYear() + 1); + } else { + periodEnd.setMonth(periodEnd.getMonth() + 1); + } + + const subscriptionId = createId(); + const subscription = SubscriptionEntity.createNew( + subscriptionId, + command.userId, + plan.id, + command.planTier, + now, + periodEnd, + ); + + await this.subscriptionRepo.save(subscription); + + // Publish domain events + const events = subscription.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Subscription created: id=${subscriptionId}, user=${command.userId}, tier=${command.planTier}`, + 'CreateSubscriptionHandler', + ); + + return { + subscriptionId, + planTier: command.planTier, + status: 'ACTIVE', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to tạo subscription: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể tạo gói đăng ký. Vui lòng thử lại sau.'); } - - // Fetch plan - const plan = await this.prisma.plan.findFirst({ - where: { tier: command.planTier, isActive: true }, - }); - if (!plan) { - throw new NotFoundException('Plan', command.planTier); - } - - // Calculate period - const now = new Date(); - const periodEnd = new Date(now); - if (command.billingCycle === 'yearly') { - periodEnd.setFullYear(periodEnd.getFullYear() + 1); - } else { - periodEnd.setMonth(periodEnd.getMonth() + 1); - } - - const subscriptionId = createId(); - const subscription = SubscriptionEntity.createNew( - subscriptionId, - command.userId, - plan.id, - command.planTier, - now, - periodEnd, - ); - - await this.subscriptionRepo.save(subscription); - - // Publish domain events - const events = subscription.clearDomainEvents(); - for (const event of events) { - this.eventBus.publish(event); - } - - this.logger.log( - `Subscription created: id=${subscriptionId}, user=${command.userId}, tier=${command.planTier}`, - 'CreateSubscriptionHandler', - ); - - return { - subscriptionId, - planTier: command.planTier, - status: 'ACTIVE', - currentPeriodStart: now, - currentPeriodEnd: periodEnd, - }; } } diff --git a/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts index 2d4c714..0ea7448 100644 --- a/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService, type LoggerService } from '@modules/shared'; +import { DomainException, NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService, type LoggerService } from '@modules/shared'; import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository, @@ -26,63 +26,73 @@ export class MeterUsageHandler implements ICommandHandler { ) {} async execute(command: MeterUsageCommand): Promise { - if (command.count <= 0) { - throw new ValidationException('Số lượng phải lớn hơn 0'); - } + try { + if (command.count <= 0) { + throw new ValidationException('Số lượng phải lớn hơn 0'); + } - const subscription = await this.subscriptionRepo.findByUserId(command.userId); - if (!subscription) { - throw new NotFoundException('Subscription', command.userId); - } + const subscription = await this.subscriptionRepo.findByUserId(command.userId); + if (!subscription) { + throw new NotFoundException('Subscription', command.userId); + } - if (!subscription.isActive()) { - throw new ValidationException('Subscription không ở trạng thái hoạt động'); - } + if (!subscription.isActive()) { + throw new ValidationException('Subscription không ở trạng thái hoạt động'); + } - // Upsert usage record for current period + metric - const existing = await this.prisma.usageRecord.findFirst({ - where: { - subscriptionId: subscription.id, - metric: command.metric, - periodStart: subscription.currentPeriodStart, - periodEnd: subscription.currentPeriodEnd, - }, - }); - - let usageRecord; - if (existing) { - usageRecord = await this.prisma.usageRecord.update({ - where: { id: existing.id }, - data: { count: existing.count + command.count }, - }); - } else { - usageRecord = await this.prisma.usageRecord.create({ - data: { + // Upsert usage record for current period + metric + const existing = await this.prisma.usageRecord.findFirst({ + where: { subscriptionId: subscription.id, metric: command.metric, - count: command.count, periodStart: subscription.currentPeriodStart, periodEnd: subscription.currentPeriodEnd, }, }); + + let usageRecord; + if (existing) { + usageRecord = await this.prisma.usageRecord.update({ + where: { id: existing.id }, + data: { count: existing.count + command.count }, + }); + } else { + usageRecord = await this.prisma.usageRecord.create({ + data: { + subscriptionId: subscription.id, + metric: command.metric, + count: command.count, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }, + }); + } + + // Invalidate cached quota for this user + metric + await this.cache.invalidate( + CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric), + ); + + this.logger.log( + `Usage metered: subscription=${subscription.id}, metric=${command.metric}, count=${command.count}`, + 'MeterUsageHandler', + ); + + return { + usageRecordId: usageRecord.id, + metric: usageRecord.metric, + count: usageRecord.count, + periodStart: usageRecord.periodStart, + periodEnd: usageRecord.periodEnd, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to ghi nhận mức sử dụng: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể ghi nhận mức sử dụng. Vui lòng thử lại sau.'); } - - // Invalidate cached quota for this user + metric - await this.cache.invalidate( - CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric), - ); - - this.logger.log( - `Usage metered: subscription=${subscription.id}, metric=${command.metric}, count=${command.count}`, - 'MeterUsageHandler', - ); - - return { - usageRecordId: usageRecord.id, - metric: usageRecord.metric, - count: usageRecord.count, - periodStart: usageRecord.periodStart, - periodEnd: usageRecord.periodEnd, - }; } } 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 b6d983d..2f40e28 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 } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService, type LoggerService } from '@modules/shared'; +import { DomainException, NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService, type LoggerService } from '@modules/shared'; import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository, @@ -28,65 +28,75 @@ export class UpgradeSubscriptionHandler implements ICommandHandler { - const subscription = await this.subscriptionRepo.findByUserId(command.userId); - if (!subscription) { - throw new NotFoundException('Subscription', command.userId); - } - - if (!subscription.isActive()) { - throw new ValidationException('Subscription không ở trạng thái hoạt động'); - } - - // Validate upgrade direction - const currentOrder = TIER_ORDER[subscription.planTier]; - const newOrder = TIER_ORDER[command.newPlanTier]; - if (newOrder <= currentOrder && command.newPlanTier !== subscription.planTier) { - // Allow same-tier for AGENT_PRO <-> INVESTOR switches - if (currentOrder !== newOrder) { - throw new ValidationException('Chỉ có thể nâng cấp lên gói cao hơn'); + try { + const subscription = await this.subscriptionRepo.findByUserId(command.userId); + if (!subscription) { + throw new NotFoundException('Subscription', command.userId); } + + if (!subscription.isActive()) { + throw new ValidationException('Subscription không ở trạng thái hoạt động'); + } + + // Validate upgrade direction + const currentOrder = TIER_ORDER[subscription.planTier]; + const newOrder = TIER_ORDER[command.newPlanTier]; + if (newOrder <= currentOrder && command.newPlanTier !== subscription.planTier) { + // Allow same-tier for AGENT_PRO <-> INVESTOR switches + if (currentOrder !== newOrder) { + throw new ValidationException('Chỉ có thể nâng cấp lên gói cao hơn'); + } + } + + if (command.newPlanTier === subscription.planTier) { + throw new ValidationException('Đã đang sử dụng gói này'); + } + + // Fetch new plan + const newPlan = await this.prisma.plan.findFirst({ + where: { tier: command.newPlanTier, isActive: true }, + }); + if (!newPlan) { + throw new NotFoundException('Plan', command.newPlanTier); + } + + const previousTier = subscription.planTier; + const upgradeResult = subscription.upgrade(newPlan.id, command.newPlanTier); + if (upgradeResult.isErr) { + throw upgradeResult.unwrapErr(); + } + await this.subscriptionRepo.update(subscription); + + // Publish domain events + const events = subscription.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + // Invalidate all cached quota entries for this user (limits change with plan) + await this.cache.invalidateByPrefix( + CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId), + ); + + this.logger.log( + `Subscription upgraded: id=${subscription.id}, ${previousTier} → ${command.newPlanTier}`, + 'UpgradeSubscriptionHandler', + ); + + return { + subscriptionId: subscription.id, + previousTier, + newTier: command.newPlanTier, + status: subscription.status, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to nâng cấp subscription: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể nâng cấp gói đăng ký. Vui lòng thử lại sau.'); } - - if (command.newPlanTier === subscription.planTier) { - throw new ValidationException('Đã đang sử dụng gói này'); - } - - // Fetch new plan - const newPlan = await this.prisma.plan.findFirst({ - where: { tier: command.newPlanTier, isActive: true }, - }); - if (!newPlan) { - throw new NotFoundException('Plan', command.newPlanTier); - } - - const previousTier = subscription.planTier; - const upgradeResult = subscription.upgrade(newPlan.id, command.newPlanTier); - if (upgradeResult.isErr) { - throw upgradeResult.unwrapErr(); - } - await this.subscriptionRepo.update(subscription); - - // Publish domain events - const events = subscription.clearDomainEvents(); - for (const event of events) { - this.eventBus.publish(event); - } - - // Invalidate all cached quota entries for this user (limits change with plan) - await this.cache.invalidateByPrefix( - CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId), - ); - - this.logger.log( - `Subscription upgraded: id=${subscription.id}, ${previousTier} → ${command.newPlanTier}`, - 'UpgradeSubscriptionHandler', - ); - - return { - subscriptionId: subscription.id, - previousTier, - newTier: command.newPlanTier, - status: subscription.status, - }; } } diff --git a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts index 22cd1d9..66176b9 100644 --- a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts @@ -1,7 +1,7 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { type Plan } from '@prisma/client'; -import { NotFoundException, CacheService, CachePrefix, CacheTTL, type PrismaService } from '@modules/shared'; +import { DomainException, NotFoundException, CacheService, CachePrefix, CacheTTL, type PrismaService, type LoggerService } from '@modules/shared'; import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository, @@ -30,17 +30,28 @@ export class CheckQuotaHandler implements IQueryHandler { private readonly subscriptionRepo: ISubscriptionRepository, private readonly prisma: PrismaService, private readonly cache: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: CheckQuotaQuery): Promise { - const cacheKey = CacheService.buildKey(CachePrefix.USER_QUOTA, query.userId, query.metric); + try { + const cacheKey = CacheService.buildKey(CachePrefix.USER_QUOTA, query.userId, query.metric); - return this.cache.getOrSet( - cacheKey, - () => this.loadQuota(query.userId, query.metric), - CacheTTL.USER_QUOTA, - 'quota', - ); + return this.cache.getOrSet( + cacheKey, + () => this.loadQuota(query.userId, query.metric), + CacheTTL.USER_QUOTA, + 'quota', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to kiểm tra hạn mức sử dụng: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể kiểm tra hạn mức sử dụng. Vui lòng thử lại sau.'); + } } private async loadQuota(userId: string, metric: string): Promise { diff --git a/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts b/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts index 402ccc6..0c7ac30 100644 --- a/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts @@ -1,6 +1,6 @@ -import { Inject } from '@nestjs/common'; +import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { type PrismaService } from '@modules/shared'; +import { DomainException, type PrismaService, type LoggerService } from '@modules/shared'; import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository, @@ -33,47 +33,58 @@ export class GetBillingHistoryHandler implements IQueryHandler { - const subscription = await this.subscriptionRepo.findByUserId(query.userId); + try { + const subscription = await this.subscriptionRepo.findByUserId(query.userId); - // Fetch subscription-related payments - const where = { - userId: query.userId, - type: 'SUBSCRIPTION' as const, - }; + // Fetch subscription-related payments + const where = { + userId: query.userId, + type: 'SUBSCRIPTION' as const, + }; - const [payments, total] = await Promise.all([ - this.prisma.payment.findMany({ - where, - orderBy: { createdAt: 'desc' }, - take: query.limit ?? 20, - skip: query.offset ?? 0, - }), - this.prisma.payment.count({ where }), - ]); + const [payments, total] = await Promise.all([ + this.prisma.payment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: query.limit ?? 20, + skip: query.offset ?? 0, + }), + this.prisma.payment.count({ where }), + ]); - return { - subscription: subscription - ? { - id: subscription.id, - planTier: subscription.planTier, - status: subscription.status, - currentPeriodStart: subscription.currentPeriodStart, - currentPeriodEnd: subscription.currentPeriodEnd, - cancelledAt: subscription.cancelledAt, - createdAt: subscription.createdAt, - } - : null, - payments: payments.map((p) => ({ - id: p.id, - amountVND: p.amountVND.toString(), - status: p.status, - provider: p.provider, - createdAt: p.createdAt, - })), - total, - }; + return { + subscription: subscription + ? { + id: subscription.id, + planTier: subscription.planTier, + status: subscription.status, + currentPeriodStart: subscription.currentPeriodStart, + currentPeriodEnd: subscription.currentPeriodEnd, + cancelledAt: subscription.cancelledAt, + createdAt: subscription.createdAt, + } + : null, + payments: payments.map((p) => ({ + id: p.id, + amountVND: p.amountVND.toString(), + status: p.status, + provider: p.provider, + createdAt: p.createdAt, + })), + total, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to truy vấn lịch sử thanh toán: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể truy vấn lịch sử thanh toán. Vui lòng thử lại sau.'); + } } } diff --git a/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts index 3324c0f..77505ea 100644 --- a/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts @@ -1,6 +1,7 @@ +import { InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { type Plan } from '@prisma/client'; -import { CacheService, CachePrefix, CacheTTL, NotFoundException, type PrismaService } from '@modules/shared'; +import { DomainException, CacheService, CachePrefix, CacheTTL, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared'; import { GetPlanQuery } from './get-plan.query'; export interface PlanDto { @@ -20,38 +21,49 @@ export class GetPlanHandler implements IQueryHandler { constructor( private readonly prisma: PrismaService, private readonly cacheService: CacheService, + private readonly logger: LoggerService, ) {} async execute(query: GetPlanQuery): Promise { - if (query.planTier) { - const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, query.planTier); + try { + if (query.planTier) { + const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, query.planTier); + return this.cacheService.getOrSet( + cacheKey, + async () => { + const plan = await this.prisma.plan.findFirst({ + where: { tier: query.planTier, isActive: true }, + }); + if (!plan) throw new NotFoundException('Plan', query.planTier); + return this.toDto(plan); + }, + CacheTTL.PLAN_LIST, + 'plan', + ); + } + + const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, 'all'); return this.cacheService.getOrSet( cacheKey, async () => { - const plan = await this.prisma.plan.findFirst({ - where: { tier: query.planTier, isActive: true }, + const plans = await this.prisma.plan.findMany({ + where: { isActive: true }, + orderBy: { priceMonthlyVND: 'asc' }, }); - if (!plan) throw new NotFoundException('Plan', query.planTier); - return this.toDto(plan); + return plans.map((p) => this.toDto(p)); }, CacheTTL.PLAN_LIST, 'plan', ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to truy vấn thông tin gói dịch vụ: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể truy vấn thông tin gói dịch vụ. Vui lòng thử lại sau.'); } - - const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, 'all'); - return this.cacheService.getOrSet( - cacheKey, - async () => { - const plans = await this.prisma.plan.findMany({ - where: { isActive: true }, - orderBy: { priceMonthlyVND: 'asc' }, - }); - return plans.map((p) => this.toDto(p)); - }, - CacheTTL.PLAN_LIST, - 'plan', - ); } private toDto(plan: Plan): PlanDto {