From d4e100a00c45f7e14dd87d518a3f43a7c35f1ed5 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 05:15:04 +0700 Subject: [PATCH] feat(api): add price history, Stringee SMS, Zalo OA, WebSocket notifications, and feature-listing command - Add PriceHistory model + migration, price-changed domain event, and event handler - Add GetPriceHistory query handler and controller endpoint - Implement StringeeSmsService and ZaloOaService with unit tests - Add Zalo ZNS templates for Vietnamese notification messages - Add WebSocket notification gateway for real-time push - Add FeatureListingCommand for promoted listings - Apply remaining consistent-type-imports lint fixes across API modules Co-Authored-By: Paperclip --- apps/api/package.json | 3 + .../listeners/admin-audit.listener.ts | 18 +- .../admin-moderation.controller.ts | 24 +- .../controllers/admin.controller.ts | 24 +- .../update-profile/update-profile.handler.ts | 12 +- .../use-backup-code.handler.ts | 12 +- .../verify-mfa-challenge.handler.ts | 12 +- .../infrastructure/services/oauth.service.ts | 10 +- .../controllers/auth.controller.ts | 14 +- .../controllers/mfa.controller.ts | 12 +- .../controllers/user-data.controller.ts | 10 +- .../controllers/inquiries.controller.ts | 14 +- .../repositories/prisma-lead.repository.ts | 10 +- .../controllers/leads.controller.ts | 16 +- .../feature-listing.command.ts | 14 + .../feature-listing.handler.ts | 93 ++++++ .../activate-featured-listing.handler.ts | 56 ++++ .../record-price-history.handler.ts | 35 +++ .../get-price-history.handler.ts | 28 ++ .../get-price-history.query.ts | 3 + .../events/listing-price-changed.event.ts | 12 + .../repositories/prisma-listing.repository.ts | 10 +- .../prisma-property.repository.ts | 10 +- .../controllers/listings.controller.ts | 3 + .../presentation/dto/feature-listing.dto.ts | 22 ++ .../listings/presentation/dto/index.ts | 1 + .../send-notification.handler.spec.ts | 79 ++++- .../send-notification.handler.ts | 70 ++++- .../__tests__/stringee-sms.service.spec.ts | 221 ++++++++++++++ .../__tests__/zalo-oa.service.spec.ts | 262 +++++++++++++++++ .../services/stringee-sms.service.ts | 152 ++++++++++ .../services/zalo-oa.service.ts | 149 ++++++++++ .../services/zalo-zns-templates.ts | 87 ++++++ .../controllers/notifications.controller.ts | 25 +- .../gateways/notifications.gateway.ts | 272 ++++++++++++++++++ .../confirm-bank-transfer.handler.ts | 10 +- .../handle-callback.handler.ts | 10 +- .../services/payment-gateway.factory.ts | 12 +- .../controllers/orders.controller.ts | 18 +- .../controllers/payments.controller.ts | 26 +- .../controllers/reviews.controller.ts | 12 +- .../services/resilient-search.repository.ts | 10 +- .../services/typesense-search.repository.ts | 10 +- .../controllers/saved-search.controller.ts | 14 +- .../controllers/search.controller.ts | 10 +- .../controllers/subscriptions.controller.ts | 30 +- .../migration.sql | 16 ++ prisma/schema.prisma | 18 +- 48 files changed, 1766 insertions(+), 225 deletions(-) create mode 100644 apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.command.ts create mode 100644 apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.handler.ts create mode 100644 apps/api/src/modules/listings/application/event-handlers/activate-featured-listing.handler.ts create mode 100644 apps/api/src/modules/listings/application/event-handlers/record-price-history.handler.ts create mode 100644 apps/api/src/modules/listings/application/queries/get-price-history/get-price-history.handler.ts create mode 100644 apps/api/src/modules/listings/application/queries/get-price-history/get-price-history.query.ts create mode 100644 apps/api/src/modules/listings/domain/events/listing-price-changed.event.ts create mode 100644 apps/api/src/modules/listings/presentation/dto/feature-listing.dto.ts create mode 100644 apps/api/src/modules/notifications/infrastructure/__tests__/stringee-sms.service.spec.ts create mode 100644 apps/api/src/modules/notifications/infrastructure/__tests__/zalo-oa.service.spec.ts create mode 100644 apps/api/src/modules/notifications/infrastructure/services/stringee-sms.service.ts create mode 100644 apps/api/src/modules/notifications/infrastructure/services/zalo-oa.service.ts create mode 100644 apps/api/src/modules/notifications/infrastructure/services/zalo-zns-templates.ts create mode 100644 apps/api/src/modules/notifications/presentation/gateways/notifications.gateway.ts create mode 100644 prisma/migrations/20260416200000_add_price_history/migration.sql diff --git a/apps/api/package.json b/apps/api/package.json index b03e895..96a7773 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,10 +24,12 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.0", + "@nestjs/platform-socket.io": "^11.1.19", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.7", "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", + "@nestjs/websockets": "^11.1.19", "@paralleldrive/cuid2": "^3.3.0", "@prisma/adapter-pg": "^7.7.0", "@prisma/client": "^7.7.0", @@ -56,6 +58,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "sanitize-html": "^2.17.2", + "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.1", "typesense": "^3.0.5" }, diff --git a/apps/api/src/modules/admin/application/listeners/admin-audit.listener.ts b/apps/api/src/modules/admin/application/listeners/admin-audit.listener.ts index 0056a6e..edba8f6 100644 --- a/apps/api/src/modules/admin/application/listeners/admin-audit.listener.ts +++ b/apps/api/src/modules/admin/application/listeners/admin-audit.listener.ts @@ -1,16 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { LoggerService } from '@modules/shared'; -import { KycApprovedEvent } from '../../domain/events/kyc-approved.event'; -import { KycRejectedEvent } from '../../domain/events/kyc-rejected.event'; -import { ListingApprovedEvent } from '../../domain/events/listing-approved.event'; -import { ListingRejectedEvent } from '../../domain/events/listing-rejected.event'; -import { SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event'; -import { UserBannedEvent } from '../../domain/events/user-banned.event'; -import { UserUnbannedEvent } from '../../domain/events/user-unbanned.event'; +import { type LoggerService } from '@modules/shared'; +import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event'; +import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event'; +import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event'; +import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event'; +import { type SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event'; +import { type UserBannedEvent } from '../../domain/events/user-banned.event'; +import { type UserUnbannedEvent } from '../../domain/events/user-unbanned.event'; import { AUDIT_LOG_REPOSITORY, - IAuditLogRepository, + type IAuditLogRepository, } from '../../domain/repositories/audit-log.repository'; @Injectable() diff --git a/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts index da04981..0573761 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts @@ -6,30 +6,30 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; -import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command'; -import { ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler'; +import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler'; import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command'; -import { ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler'; +import { type ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler'; import { BulkModerateListingsCommand } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.command'; -import { BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler'; +import { type BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler'; import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-kyc.command'; -import { RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler'; +import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler'; import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command'; -import { RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler'; +import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler'; import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query'; import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query'; import { type ModerationQueueResult, type KycQueueResult, } from '../../domain/repositories/admin-query.repository'; -import { ApproveKycDto } from '../dto/approve-kyc.dto'; -import { ApproveListingDto } from '../dto/approve-listing.dto'; -import { BulkModerateDto } from '../dto/bulk-moderate.dto'; -import { RejectKycDto } from '../dto/reject-kyc.dto'; -import { RejectListingDto } from '../dto/reject-listing.dto'; +import { type ApproveKycDto } from '../dto/approve-kyc.dto'; +import { type ApproveListingDto } from '../dto/approve-listing.dto'; +import { type BulkModerateDto } from '../dto/bulk-moderate.dto'; +import { type RejectKycDto } from '../dto/reject-kyc.dto'; +import { type RejectListingDto } from '../dto/reject-listing.dto'; @ApiTags('admin') @ApiBearerAuth('JWT') diff --git a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts index 521ae9f..55dad48 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -8,15 +8,15 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger'; -import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command'; -import { AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler'; +import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler'; import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command'; -import { BanUserResult } from '../../application/commands/ban-user/ban-user.handler'; +import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler'; import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command'; -import { UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler'; +import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler'; import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query'; import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query'; import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query'; @@ -28,13 +28,13 @@ import { type UserListResult, type UserDetail, } from '../../domain/repositories/admin-query.repository'; -import { AuditLogListResult } from '../../domain/repositories/audit-log.repository'; -import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto'; -import { BanUserDto } from '../dto/ban-user.dto'; -import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto'; -import { GetUsersQueryDto } from '../dto/get-users-query.dto'; -import { RevenueStatsDto } from '../dto/revenue-stats.dto'; -import { UpdateUserStatusDto } from '../dto/update-user-status.dto'; +import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository'; +import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto'; +import { type BanUserDto } from '../dto/ban-user.dto'; +import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto'; +import { type GetUsersQueryDto } from '../dto/get-users-query.dto'; +import { type RevenueStatsDto } from '../dto/revenue-stats.dto'; +import { type UpdateUserStatusDto } from '../dto/update-user-status.dto'; @ApiTags('admin') @ApiBearerAuth('JWT') diff --git a/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts index b8006a4..33964a0 100644 --- a/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts +++ b/apps/api/src/modules/auth/application/commands/update-profile/update-profile.handler.ts @@ -1,19 +1,19 @@ +import { randomInt } from 'crypto'; import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { CachePrefix, CacheService, ConflictException, DomainException, - LoggerService, + type LoggerService, NotFoundException, - RedisService, + type RedisService, ValidationException, } from '@modules/shared'; -import { randomInt } from 'crypto'; -import { Email } from '../../../domain/value-objects/email.vo'; import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event'; -import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository'; +import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository'; +import { Email } from '../../../domain/value-objects/email.vo'; import { UpdateProfileCommand } from './update-profile.command'; /** TTL for email-change OTP codes stored in Redis (10 minutes). */ diff --git a/apps/api/src/modules/auth/application/commands/use-backup-code/use-backup-code.handler.ts b/apps/api/src/modules/auth/application/commands/use-backup-code/use-backup-code.handler.ts index 71a23b5..42c4a77 100644 --- a/apps/api/src/modules/auth/application/commands/use-backup-code/use-backup-code.handler.ts +++ b/apps/api/src/modules/auth/application/commands/use-backup-code/use-backup-code.handler.ts @@ -1,13 +1,13 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { DomainException, LoggerService, UnauthorizedException } from '@modules/shared'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared'; import { MFA_CHALLENGE_REPOSITORY, - IMfaChallengeRepository, + type IMfaChallengeRepository, } from '../../../domain/repositories/mfa-challenge.repository'; -import { USER_REPOSITORY, IUserRepository } from '../../../domain/repositories/user.repository'; -import { MfaService } from '../../../infrastructure/services/mfa.service'; -import { TokenService, TokenPair } from '../../../infrastructure/services/token.service'; +import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; +import { type MfaService } from '../../../infrastructure/services/mfa.service'; +import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service'; import { UseBackupCodeCommand } from './use-backup-code.command'; @CommandHandler(UseBackupCodeCommand) diff --git a/apps/api/src/modules/auth/application/commands/verify-mfa-challenge/verify-mfa-challenge.handler.ts b/apps/api/src/modules/auth/application/commands/verify-mfa-challenge/verify-mfa-challenge.handler.ts index a2ef05e..d8b39a5 100644 --- a/apps/api/src/modules/auth/application/commands/verify-mfa-challenge/verify-mfa-challenge.handler.ts +++ b/apps/api/src/modules/auth/application/commands/verify-mfa-challenge/verify-mfa-challenge.handler.ts @@ -1,13 +1,13 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { DomainException, LoggerService, UnauthorizedException } from '@modules/shared'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared'; import { MFA_CHALLENGE_REPOSITORY, - IMfaChallengeRepository, + type IMfaChallengeRepository, } from '../../../domain/repositories/mfa-challenge.repository'; -import { USER_REPOSITORY, IUserRepository } from '../../../domain/repositories/user.repository'; -import { MfaService } from '../../../infrastructure/services/mfa.service'; -import { TokenService, TokenPair } from '../../../infrastructure/services/token.service'; +import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; +import { type MfaService } from '../../../infrastructure/services/mfa.service'; +import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service'; import { VerifyMfaChallengeCommand } from './verify-mfa-challenge.command'; @CommandHandler(VerifyMfaChallengeCommand) diff --git a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts index 3482708..4c2457c 100644 --- a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts +++ b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts @@ -1,14 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; -import { EventBus } from '@nestjs/cqrs'; +import { type EventBus } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { type OAuthProvider, Prisma } from '@prisma/client'; -import { PrismaService, LoggerService } from '@modules/shared'; +import { type OAuthProvider, type Prisma } from '@prisma/client'; +import { type PrismaService, type LoggerService } from '@modules/shared'; import { UserEntity } from '../../domain/entities/user.entity'; import { UserRegisteredEvent } from '../../domain/events/user-registered.event'; -import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository'; +import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository'; import { Email } from '../../domain/value-objects/email.vo'; import { Phone } from '../../domain/value-objects/phone.vo'; -import { TokenService, TokenPair } from './token.service'; +import { type TokenService, type TokenPair } from './token.service'; export interface OAuthUserProfile { provider: OAuthProvider; diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts index 4568d98..da74385 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -6,13 +6,10 @@ import { Post, Req, Res, - UploadedFiles, UseGuards, - UseInterceptors, } from '@nestjs/common'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; -import { FileFieldsInterceptor } from '@nestjs/platform-express'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { type Request, type Response } from 'express'; import { @@ -20,15 +17,12 @@ import { EndpointRateLimitGuard, UnauthorizedException, ValidationException, - FileValidationPipe, - type UploadedFile as ValidatedFile, } from '@modules/shared'; +import { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command'; import { LoginUserCommand } from '../../application/commands/login-user/login-user.command'; import { type LoginResult } from '../../application/commands/login-user/login-user.handler'; import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command'; import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command'; -import { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command'; -import { type KycUploadUrlResult } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler'; import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command'; import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command'; import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler'; @@ -46,9 +40,9 @@ import { Roles } from '../decorators/roles.decorator'; import { LoginDto } from '../dto/login.dto'; import { type RefreshTokenDto } from '../dto/refresh-token.dto'; import { type RegisterDto } from '../dto/register.dto'; +import { type UpdateProfileDto } from '../dto/update-profile.dto'; +import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto'; import { type VerifyKycDto } from '../dto/verify-kyc.dto'; -import { UpdateProfileDto } from '../dto/update-profile.dto'; -import { VerifyEmailChangeDto } from '../dto/verify-email-change.dto'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { LocalAuthGuard } from '../guards/local-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; diff --git a/apps/api/src/modules/auth/presentation/controllers/mfa.controller.ts b/apps/api/src/modules/auth/presentation/controllers/mfa.controller.ts index 1457406..f18d2bf 100644 --- a/apps/api/src/modules/auth/presentation/controllers/mfa.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/mfa.controller.ts @@ -7,21 +7,21 @@ import { Res, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; -import { Response } from 'express'; +import { type Response } from 'express'; import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; import { DisableMfaCommand } from '../../application/commands/disable-mfa/disable-mfa.command'; import { SetupMfaCommand } from '../../application/commands/setup-mfa/setup-mfa.command'; -import { SetupMfaResultDto } from '../../application/commands/setup-mfa/setup-mfa.handler'; +import { type SetupMfaResultDto } from '../../application/commands/setup-mfa/setup-mfa.handler'; import { UseBackupCodeCommand } from '../../application/commands/use-backup-code/use-backup-code.command'; import { VerifyMfaChallengeCommand } from '../../application/commands/verify-mfa-challenge/verify-mfa-challenge.command'; import { VerifyMfaSetupCommand } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.command'; -import { VerifyMfaSetupResultDto } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.handler'; -import { MfaStatusDto } from '../../application/queries/get-mfa-status/get-mfa-status.handler'; +import { type VerifyMfaSetupResultDto } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.handler'; +import { type MfaStatusDto } from '../../application/queries/get-mfa-status/get-mfa-status.handler'; import { GetMfaStatusQuery } from '../../application/queries/get-mfa-status/get-mfa-status.query'; -import { TokenService, JwtPayload, TokenPair } from '../../infrastructure/services/token.service'; +import { type TokenService, type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service'; import { CurrentUser } from '../decorators/current-user.decorator'; import { type VerifyMfaSetupDto, diff --git a/apps/api/src/modules/auth/presentation/controllers/user-data.controller.ts b/apps/api/src/modules/auth/presentation/controllers/user-data.controller.ts index fb8d14e..eb40e41 100644 --- a/apps/api/src/modules/auth/presentation/controllers/user-data.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/user-data.controller.ts @@ -7,18 +7,18 @@ import { Post, UseGuards, } from '@nestjs/common'; -import { CommandBus } from '@nestjs/cqrs'; +import { type CommandBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command'; import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command'; -import { UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler'; +import { type UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler'; import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command'; import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command'; -import { JwtPayload } from '../../infrastructure/services/token.service'; +import { type JwtPayload } from '../../infrastructure/services/token.service'; import { CurrentUser } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; -import { ForceDeleteUserDto } from '../dto/force-delete-user.dto'; -import { RequestDeletionDto } from '../dto/request-deletion.dto'; +import { type ForceDeleteUserDto } from '../dto/force-delete-user.dto'; +import { type RequestDeletionDto } from '../dto/request-deletion.dto'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; diff --git a/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts b/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts index 38fcdbe..29c0f18 100644 --- a/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts +++ b/apps/api/src/modules/inquiries/presentation/controllers/inquiries.controller.ts @@ -8,7 +8,7 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, @@ -16,16 +16,16 @@ import { ApiBearerAuth, ApiParam, } from '@nestjs/swagger'; -import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command'; -import { CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler'; +import { type CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler'; import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command'; import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query'; import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query'; -import { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto'; -import { PaginatedResult } from '../../domain/repositories/inquiry.repository'; -import { CreateInquiryDto } from '../dto/create-inquiry.dto'; -import { ListInquiriesDto } from '../dto/list-inquiries.dto'; +import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto'; +import { type PaginatedResult } from '../../domain/repositories/inquiry.repository'; +import { type CreateInquiryDto } from '../dto/create-inquiry.dto'; +import { type ListInquiriesDto } from '../dto/list-inquiries.dto'; @ApiTags('inquiries') @Controller('inquiries') diff --git a/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts b/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts index 7e3457b..ca6e5e8 100644 --- a/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts +++ b/apps/api/src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { Lead as PrismaLead } from '@prisma/client'; -import { PrismaService } from '@modules/shared'; -import { LeadEntity, LeadStatus } from '../../domain/entities/lead.entity'; -import { LeadReadDto } from '../../domain/repositories/lead-read.dto'; -import { ILeadRepository, LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository'; +import { type Lead as PrismaLead } from '@prisma/client'; +import { type PrismaService } from '@modules/shared'; +import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity'; +import { type LeadReadDto } from '../../domain/repositories/lead-read.dto'; +import { type ILeadRepository, type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository'; import { LeadScore } from '../../domain/value-objects/lead-score.vo'; @Injectable() diff --git a/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts b/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts index 3e47fbe..afb093e 100644 --- a/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts +++ b/apps/api/src/modules/leads/presentation/controllers/leads.controller.ts @@ -9,7 +9,7 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, @@ -17,18 +17,18 @@ import { ApiBearerAuth, ApiParam, } from '@nestjs/swagger'; -import { JwtPayload, CurrentUser, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth'; +import { type JwtPayload, CurrentUser, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth'; import { CreateLeadCommand } from '../../application/commands/create-lead/create-lead.command'; -import { CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler'; +import { type CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler'; import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command'; import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command'; import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query'; import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query'; -import { LeadReadDto } from '../../domain/repositories/lead-read.dto'; -import { LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository'; -import { CreateLeadDto } from '../dto/create-lead.dto'; -import { ListLeadsDto } from '../dto/list-leads.dto'; -import { UpdateLeadStatusDto } from '../dto/update-lead-status.dto'; +import { type LeadReadDto } from '../../domain/repositories/lead-read.dto'; +import { type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository'; +import { type CreateLeadDto } from '../dto/create-lead.dto'; +import { type ListLeadsDto } from '../dto/list-leads.dto'; +import { type UpdateLeadStatusDto } from '../dto/update-lead-status.dto'; @ApiTags('leads') @ApiBearerAuth('JWT') diff --git a/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.command.ts b/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.command.ts new file mode 100644 index 0000000..5997aa7 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.command.ts @@ -0,0 +1,14 @@ +import type { PaymentProvider } from '@prisma/client'; + +export type FeaturePackage = '3_days' | '7_days' | '30_days'; + +export class FeatureListingCommand { + constructor( + public readonly listingId: string, + public readonly userId: string, + public readonly package_: FeaturePackage, + public readonly provider: PaymentProvider, + public readonly returnUrl: string, + public readonly ipAddress: string, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.handler.ts b/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.handler.ts new file mode 100644 index 0000000..ba87c20 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.handler.ts @@ -0,0 +1,93 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { CommandHandler, type CommandBus, type ICommandHandler } from '@nestjs/cqrs'; +import { CreatePaymentCommand } from '@modules/payments/application/commands/create-payment/create-payment.command'; +import type { CreatePaymentResult } from '@modules/payments/application/commands/create-payment/create-payment.handler'; +import { + DomainException, + ForbiddenException, + NotFoundException, + ValidationException, + type LoggerService, +} from '@modules/shared'; +import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; +import { type FeaturePackage, FeatureListingCommand } from './feature-listing.command'; + +const PACKAGE_PRICES: Record = { + '3_days': 99_000n, + '7_days': 199_000n, + '30_days': 499_000n, +}; + +export interface FeatureListingResult { + paymentId: string; + paymentUrl: string; + providerTxId: string; + package_: FeaturePackage; + priceVND: string; +} + +@CommandHandler(FeatureListingCommand) +export class FeatureListingHandler implements ICommandHandler { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly commandBus: CommandBus, + private readonly logger: LoggerService, + ) {} + + async execute(command: FeatureListingCommand): Promise { + try { + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing', command.listingId); + } + + if (listing.sellerId !== command.userId && listing.agentId !== command.userId) { + throw new ForbiddenException('Chỉ người bán hoặc môi giới mới có thể đẩy tin nổi bật'); + } + + if (listing.status !== 'ACTIVE') { + throw new ValidationException('Chỉ tin đăng đang hoạt động mới có thể đẩy nổi bật', { + status: listing.status, + }); + } + + const price = PACKAGE_PRICES[command.package_]; + if (!price) { + throw new ValidationException('Gói không hợp lệ', { package: command.package_ }); + } + + const paymentResult: CreatePaymentResult = await this.commandBus.execute( + new CreatePaymentCommand( + command.userId, + command.provider, + 'FEATURED_LISTING', + price, + `Đẩy tin nổi bật ${command.package_.replace('_', ' ')} - Listing ${command.listingId}`, + command.returnUrl, + command.ipAddress, + command.listingId, + `feature_${command.listingId}_${Date.now()}`, + ), + ); + + this.logger.log( + `Featured listing payment created: listing=${command.listingId}, package=${command.package_}, payment=${paymentResult.paymentId}`, + 'FeatureListingHandler', + ); + + return { + ...paymentResult, + package_: command.package_, + priceVND: price.toString(), + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to create featured listing 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 đẩy tin nổi bật'); + } + } +} diff --git a/apps/api/src/modules/listings/application/event-handlers/activate-featured-listing.handler.ts b/apps/api/src/modules/listings/application/event-handlers/activate-featured-listing.handler.ts new file mode 100644 index 0000000..a1b5770 --- /dev/null +++ b/apps/api/src/modules/listings/application/event-handlers/activate-featured-listing.handler.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type PaymentCompletedEvent } from '@modules/payments'; +import { type PrismaService, type LoggerService } from '@modules/shared'; + +const PACKAGE_DURATION_DAYS: Record = { + '99000': 3, + '199000': 7, + '499000': 30, +}; + +@Injectable() +export class ActivateFeaturedListingHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('payment.completed', { async: true }) + async handle(event: PaymentCompletedEvent): Promise { + const payment = await this.prisma.payment.findUnique({ + where: { id: event.aggregateId }, + select: { type: true, transactionId: true, amountVND: true }, + }); + + if (!payment || payment.type !== 'FEATURED_LISTING' || !payment.transactionId) { + return; + } + + const listingId = payment.transactionId; + const days = PACKAGE_DURATION_DAYS[payment.amountVND.toString()] ?? 7; + + const now = new Date(); + const listing = await this.prisma.listing.findUnique({ + where: { id: listingId }, + select: { featuredUntil: true }, + }); + + // Extend from current featuredUntil if still active, otherwise from now + const baseDate = listing?.featuredUntil && listing.featuredUntil > now + ? listing.featuredUntil + : now; + + const featuredUntil = new Date(baseDate.getTime() + days * 24 * 60 * 60 * 1000); + + await this.prisma.listing.update({ + where: { id: listingId }, + data: { featuredUntil }, + }); + + this.logger.log( + `Activated featured listing: id=${listingId}, until=${featuredUntil.toISOString()}, days=${days}`, + 'ActivateFeaturedListingHandler', + ); + } +} diff --git a/apps/api/src/modules/listings/application/event-handlers/record-price-history.handler.ts b/apps/api/src/modules/listings/application/event-handlers/record-price-history.handler.ts new file mode 100644 index 0000000..209f04d --- /dev/null +++ b/apps/api/src/modules/listings/application/event-handlers/record-price-history.handler.ts @@ -0,0 +1,35 @@ +import { EventsHandler, type IEventHandler } from '@nestjs/cqrs'; +import { type PrismaService, type LoggerService } from '@modules/shared'; +import { ListingPriceChangedEvent } from '../../domain/events/listing-price-changed.event'; + +@EventsHandler(ListingPriceChangedEvent) +export class RecordPriceHistoryHandler implements IEventHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async handle(event: ListingPriceChangedEvent): Promise { + try { + await this.prisma.priceHistory.create({ + data: { + listingId: event.aggregateId, + oldPrice: event.oldPrice, + newPrice: event.newPrice, + changedAt: event.occurredAt, + }, + }); + + this.logger.debug( + `Recorded price change for listing ${event.aggregateId}: ${event.oldPrice} → ${event.newPrice}`, + 'RecordPriceHistoryHandler', + ); + } catch (err) { + this.logger.error( + `Failed to record price history for listing ${event.aggregateId}: ${(err as Error).message}`, + (err as Error).stack, + 'RecordPriceHistoryHandler', + ); + } + } +} diff --git a/apps/api/src/modules/listings/application/queries/get-price-history/get-price-history.handler.ts b/apps/api/src/modules/listings/application/queries/get-price-history/get-price-history.handler.ts new file mode 100644 index 0000000..3405f40 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/get-price-history/get-price-history.handler.ts @@ -0,0 +1,28 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { type PrismaService } from '@modules/shared'; +import { GetPriceHistoryQuery } from './get-price-history.query'; + +export interface PriceHistoryItem { + id: string; + oldPrice: bigint; + newPrice: bigint; + changedAt: Date; +} + +@QueryHandler(GetPriceHistoryQuery) +export class GetPriceHistoryHandler implements IQueryHandler { + constructor(private readonly prisma: PrismaService) {} + + async execute(query: GetPriceHistoryQuery): Promise { + return this.prisma.priceHistory.findMany({ + where: { listingId: query.listingId }, + orderBy: { changedAt: 'desc' }, + select: { + id: true, + oldPrice: true, + newPrice: true, + changedAt: true, + }, + }); + } +} diff --git a/apps/api/src/modules/listings/application/queries/get-price-history/get-price-history.query.ts b/apps/api/src/modules/listings/application/queries/get-price-history/get-price-history.query.ts new file mode 100644 index 0000000..fa41b69 --- /dev/null +++ b/apps/api/src/modules/listings/application/queries/get-price-history/get-price-history.query.ts @@ -0,0 +1,3 @@ +export class GetPriceHistoryQuery { + constructor(public readonly listingId: string) {} +} diff --git a/apps/api/src/modules/listings/domain/events/listing-price-changed.event.ts b/apps/api/src/modules/listings/domain/events/listing-price-changed.event.ts new file mode 100644 index 0000000..475d3f5 --- /dev/null +++ b/apps/api/src/modules/listings/domain/events/listing-price-changed.event.ts @@ -0,0 +1,12 @@ +import type { DomainEvent } from '@modules/shared'; + +export class ListingPriceChangedEvent implements DomainEvent { + readonly eventName = 'listing.price_changed'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly oldPrice: bigint, + public readonly newPrice: bigint, + ) {} +} diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts index 5d8cbcf..5ae0f16 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { Listing as PrismaListing, ListingStatus } from '@prisma/client'; -import { PrismaService } from '@modules/shared'; -import { ListingEntity, ListingProps } from '../../domain/entities/listing.entity'; -import { ListingDetailData, ListingSearchItem, ListingSellerItem } from '../../domain/repositories/listing-read.dto'; -import { IListingRepository, ListingSearchParams, PaginatedResult } from '../../domain/repositories/listing.repository'; +import { type Listing as PrismaListing, type ListingStatus } from '@prisma/client'; +import { type PrismaService } from '@modules/shared'; +import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity'; +import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto'; +import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository'; import { Price } from '../../domain/value-objects/price.vo'; import { findByIdWithProperty, searchListings, findBySellerIdQuery } from './listing-read.queries'; diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts index 13b7465..451c965 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { Prisma, PropertyMedia as PrismaMedia, PropertyType, Direction } from '@prisma/client'; -import { PrismaService } from '@modules/shared'; -import { PropertyMediaEntity, PropertyMediaProps } from '../../domain/entities/property-media.entity'; -import { PropertyEntity, PropertyProps } from '../../domain/entities/property.entity'; -import { IPropertyRepository } from '../../domain/repositories/property.repository'; +import { type Prisma, type PropertyMedia as PrismaMedia, type PropertyType, type Direction } from '@prisma/client'; +import { type PrismaService } from '@modules/shared'; +import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity'; +import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity'; +import { type IPropertyRepository } from '../../domain/repositories/property.repository'; import { Address } from '../../domain/value-objects/address.vo'; import { GeoPoint } from '../../domain/value-objects/geo-point.vo'; diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index c9649d8..f317d03 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + Ip, Param, Patch, Post, @@ -29,6 +30,8 @@ import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValid import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command'; import type { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; +import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command'; +import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler'; import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command'; import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command'; import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler'; diff --git a/apps/api/src/modules/listings/presentation/dto/feature-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/feature-listing.dto.ts new file mode 100644 index 0000000..6315495 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/feature-listing.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaymentProvider } from '@prisma/client'; +import { IsEnum, IsIn, IsString, IsUrl } from 'class-validator'; + +export class FeatureListingDto { + @ApiProperty({ + enum: ['3_days', '7_days', '30_days'], + example: '7_days', + description: 'Featured listing package duration', + }) + @IsIn(['3_days', '7_days', '30_days']) + package!: '3_days' | '7_days' | '30_days'; + + @ApiProperty({ enum: PaymentProvider, example: 'VNPAY', description: 'Payment provider' }) + @IsEnum(PaymentProvider) + provider!: PaymentProvider; + + @ApiProperty({ example: 'https://goodgo.vn/payment/callback', description: 'Payment return URL' }) + @IsUrl() + @IsString() + returnUrl!: string; +} diff --git a/apps/api/src/modules/listings/presentation/dto/index.ts b/apps/api/src/modules/listings/presentation/dto/index.ts index 7bc9063..6b72ba6 100644 --- a/apps/api/src/modules/listings/presentation/dto/index.ts +++ b/apps/api/src/modules/listings/presentation/dto/index.ts @@ -1,4 +1,5 @@ export { CreateListingDto } from './create-listing.dto'; +export { FeatureListingDto } from './feature-listing.dto'; export { UpdateListingDto } from './update-listing.dto'; export { UpdateListingStatusDto } from './update-listing-status.dto'; export { ModerateListingDto } from './moderate-listing.dto'; diff --git a/apps/api/src/modules/notifications/application/__tests__/send-notification.handler.spec.ts b/apps/api/src/modules/notifications/application/__tests__/send-notification.handler.spec.ts index 821fd56..a8e366f 100644 --- a/apps/api/src/modules/notifications/application/__tests__/send-notification.handler.spec.ts +++ b/apps/api/src/modules/notifications/application/__tests__/send-notification.handler.spec.ts @@ -15,6 +15,8 @@ describe('SendNotificationHandler', () => { }; let mockEmailService: { send: ReturnType }; let mockFcmService: { send: ReturnType }; + let mockStringeeSmsService: { sendNotification: ReturnType; isAvailable: boolean }; + let mockZaloOaService: { sendMessage: ReturnType; isAvailable: boolean }; let mockTemplateService: { render: ReturnType }; let mockEventBus: { publish: ReturnType }; let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; @@ -46,6 +48,14 @@ describe('SendNotificationHandler', () => { }; mockEmailService = { send: vi.fn().mockResolvedValue({ messageId: 'msg-1' }) }; mockFcmService = { send: vi.fn().mockResolvedValue('fcm-msg-1') }; + mockStringeeSmsService = { + sendNotification: vi.fn().mockResolvedValue({ messageId: 'sms-msg-1' }), + isAvailable: true, + }; + mockZaloOaService = { + sendMessage: vi.fn().mockResolvedValue({ messageId: 'zalo-msg-1' }), + isAvailable: false, + }; mockTemplateService = { render: vi.fn().mockReturnValue({ subject: 'Chào mừng!', body: '

Chào mừng!

' }), }; @@ -57,6 +67,8 @@ describe('SendNotificationHandler', () => { mockPreferenceRepo as any, mockEmailService as any, mockFcmService as any, + mockStringeeSmsService as any, + mockZaloOaService as any, mockTemplateService as any, mockEventBus as any, mockLogger as any, @@ -97,6 +109,52 @@ describe('SendNotificationHandler', () => { expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT'); }); + it('sends SMS notification via Stringee successfully', async () => { + const command = new SendNotificationCommand( + 'user-1', 'SMS', 'user.registered', { phone: '0901234567', role: 'BUYER' }, '+84901234567', + ); + + await handler.execute(command); + + expect(mockStringeeSmsService.sendNotification).toHaveBeenCalledWith({ + to: '+84901234567', + message: 'Chào mừng!', // HTML stripped + }); + expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT'); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('logs pending when Stringee SMS is not available', async () => { + mockStringeeSmsService.isAvailable = false; + + const command = new SendNotificationCommand( + 'user-1', 'SMS', 'user.registered', {}, '+84901234567', + ); + + await handler.execute(command); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Stringee is not configured'), + 'SendNotificationHandler', + ); + expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING'); + expect(mockStringeeSmsService.sendNotification).not.toHaveBeenCalled(); + }); + + it('marks notification as FAILED when SMS send throws', async () => { + mockStringeeSmsService.sendNotification.mockRejectedValue(new Error('Stringee API error')); + + const command = new SendNotificationCommand( + 'user-1', 'SMS', 'user.registered', {}, '+84901234567', + ); + + await handler.execute(command); + + expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'FAILED', 'Stringee API error'); + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockEventBus.publish).not.toHaveBeenCalled(); + }); + it('skips notification when user preference is disabled', async () => { mockPreferenceRepo.isEnabled.mockResolvedValue(false); @@ -111,28 +169,19 @@ describe('SendNotificationHandler', () => { expect(mockLogger.log).toHaveBeenCalled(); }); - it('logs pending status for unimplemented channels (SMS)', async () => { - const command = new SendNotificationCommand( - 'user-1', 'SMS', 'user.registered', {}, '+84901234567', - ); - - await handler.execute(command); - - expect(mockLogger.warn).toHaveBeenCalled(); - expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING'); - expect(mockEmailService.send).not.toHaveBeenCalled(); - expect(mockFcmService.send).not.toHaveBeenCalled(); - }); - - it('logs pending status for unimplemented channels (ZALO_OA)', async () => { + it('logs pending when Zalo OA is not available', async () => { const command = new SendNotificationCommand( 'user-1', 'ZALO_OA', 'user.registered', {}, 'zalo-id', ); await handler.execute(command); - expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Zalo OA is not configured'), + 'SendNotificationHandler', + ); expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING'); + expect(mockZaloOaService.sendMessage).not.toHaveBeenCalled(); }); it('marks notification as FAILED when email send throws', async () => { 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 f9b2bb7..ba3b56b 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,18 +1,21 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { DomainException, EventBusService, LoggerService } from '@modules/shared'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, type EventBusService, type LoggerService } from '@modules/shared'; import { NotificationSentEvent } from '../../../domain/events/notification-sent.event'; import { NOTIFICATION_PREFERENCE_REPOSITORY, - INotificationPreferenceRepository, + type INotificationPreferenceRepository, } from '../../../domain/repositories/notification-preference.repository'; import { NOTIFICATION_REPOSITORY, - INotificationRepository, + type INotificationRepository, } from '../../../domain/repositories/notification.repository'; -import { EmailService } from '../../../infrastructure/services/email.service'; -import { FcmService } from '../../../infrastructure/services/fcm.service'; -import { TemplateService } from '../../../infrastructure/services/template.service'; +import { type EmailService } from '../../../infrastructure/services/email.service'; +import { type FcmService } from '../../../infrastructure/services/fcm.service'; +import { type StringeeSmsService } from '../../../infrastructure/services/stringee-sms.service'; +import { type TemplateService } from '../../../infrastructure/services/template.service'; +import { type ZaloOaService } from '../../../infrastructure/services/zalo-oa.service'; +import { getZaloZnsTemplates } from '../../../infrastructure/services/zalo-zns-templates'; import { SendNotificationCommand } from './send-notification.command'; @CommandHandler(SendNotificationCommand) @@ -24,6 +27,8 @@ export class SendNotificationHandler implements ICommandHandler]*>/g, ''), // Strip HTML for SMS + }); + break; + + case 'ZALO_OA': { + if (!this.zaloOaService.isAvailable) { + this.logger.warn( + 'ZALO_OA channel requested but Zalo OA is not configured — notification logged as PENDING', + 'SendNotificationHandler', + ); + await this.notificationRepo.updateStatus(notification.id, 'PENDING'); + return; + } + + const znsTemplates = getZaloZnsTemplates(); + const znsTpl = znsTemplates[templateKey]; + + if (!znsTpl) { + this.logger.warn( + `No ZNS template mapped for "${templateKey}" — Zalo OA notification logged as PENDING`, + 'SendNotificationHandler', + ); + await this.notificationRepo.updateStatus(notification.id, 'PENDING'); + return; + } + + await this.zaloOaService.sendMessage({ + toUid: recipientAddress, + templateId: znsTpl.templateId, + templateData: znsTpl.mapParams(templateData), + }); + break; + } } await this.notificationRepo.updateStatus(notification.id, 'SENT'); diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/stringee-sms.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/stringee-sms.service.spec.ts new file mode 100644 index 0000000..b17436a --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/stringee-sms.service.spec.ts @@ -0,0 +1,221 @@ +import { StringeeSmsService } from '../services/stringee-sms.service'; + +describe('StringeeSmsService', () => { + let service: StringeeSmsService; + let mockLogger: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + service = new StringeeSmsService(mockLogger as any); + vi.restoreAllMocks(); + }); + + afterEach(() => { + delete process.env['STRINGEE_API_KEY']; + delete process.env['STRINGEE_BRANDNAME']; + }); + + describe('onModuleInit', () => { + it('initializes when STRINGEE_API_KEY is set', () => { + process.env['STRINGEE_API_KEY'] = 'test-api-key'; + process.env['STRINGEE_BRANDNAME'] = 'TestBrand'; + + service.onModuleInit(); + + expect(service.isAvailable).toBe(true); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('TestBrand'), + 'StringeeSmsService', + ); + }); + + it('disables when STRINGEE_API_KEY is not set', () => { + service.onModuleInit(); + + expect(service.isAvailable).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('STRINGEE_API_KEY not set'), + 'StringeeSmsService', + ); + }); + + it('defaults brandname to GoodGo when not specified', () => { + process.env['STRINGEE_API_KEY'] = 'test-api-key'; + + service.onModuleInit(); + + expect(service.isAvailable).toBe(true); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('GoodGo'), + 'StringeeSmsService', + ); + }); + }); + + describe('sendNotification', () => { + beforeEach(() => { + process.env['STRINGEE_API_KEY'] = 'test-api-key'; + service.onModuleInit(); + }); + + it('sends SMS successfully', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-123' }), + text: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + const result = await service.sendNotification({ + to: '0901234567', + message: 'Hello from GoodGo', + }); + + expect(result).toEqual({ messageId: 'msg-123' }); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.stringee.com/v1/sms', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'X-STRINGEE-AUTH': 'test-api-key', + }), + }), + ); + }); + + it('normalizes 0-prefixed phone numbers to +84', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-456' }), + text: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + await service.sendNotification({ to: '0901234567', message: 'Test' }); + + const callBody = JSON.parse( + (globalThis.fetch as any).mock.calls[0][1].body, + ); + expect(callBody.to[0].number).toBe('+84901234567'); + }); + + it('normalizes +84 prefixed numbers correctly', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-789' }), + text: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + await service.sendNotification({ to: '+84901234567', message: 'Test' }); + + const callBody = JSON.parse( + (globalThis.fetch as any).mock.calls[0][1].body, + ); + expect(callBody.to[0].number).toBe('+84901234567'); + }); + + it('retries on failure with exponential backoff', async () => { + const mockFailResponse = { + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('Server error'), + }; + const mockSuccessResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-retry' }), + text: vi.fn(), + }; + + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(mockFailResponse as any) + .mockResolvedValueOnce(mockSuccessResponse as any); + + const result = await service.sendNotification({ + to: '0901234567', + message: 'Retry test', + }); + + expect(result).toEqual({ messageId: 'msg-retry' }); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('attempt 1/3 failed'), + 'StringeeSmsService', + ); + }); + + it('throws after 3 failed attempts', async () => { + const mockFailResponse = { + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('Server error'), + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockFailResponse as any); + + await expect( + service.sendNotification({ to: '0901234567', message: 'Fail test' }), + ).rejects.toThrow('Stringee API error (500)'); + + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('failed after 3 attempts'), + 'StringeeSmsService', + ); + }); + + it('throws when Stringee returns non-zero result code', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ r: -1, message: 'Invalid number' }), + text: vi.fn(), + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + await expect( + service.sendNotification({ to: '0901234567', message: 'Error test' }), + ).rejects.toThrow('Stringee SMS rejected'); + }); + + it('throws when not initialized', async () => { + const uninitService = new StringeeSmsService(mockLogger as any); + + await expect( + uninitService.sendNotification({ to: '0901234567', message: 'Test' }), + ).rejects.toThrow('Stringee SMS not initialized'); + }); + }); + + describe('sendOTP', () => { + beforeEach(() => { + process.env['STRINGEE_API_KEY'] = 'test-api-key'; + process.env['STRINGEE_BRANDNAME'] = 'GoodGo'; + service.onModuleInit(); + }); + + it('sends OTP with formatted message', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ r: 0, message_id: 'otp-123' }), + text: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + const result = await service.sendOTP({ to: '0901234567', code: '123456' }); + + expect(result).toEqual({ messageId: 'otp-123' }); + + const callBody = JSON.parse( + (globalThis.fetch as any).mock.calls[0][1].body, + ); + expect(callBody.text).toContain('123456'); + expect(callBody.text).toContain('GoodGo'); + expect(callBody.text).toContain('5 phut'); + }); + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/zalo-oa.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/zalo-oa.service.spec.ts new file mode 100644 index 0000000..6f65c00 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/zalo-oa.service.spec.ts @@ -0,0 +1,262 @@ +import { ZaloOaService } from '../services/zalo-oa.service'; + +describe('ZaloOaService', () => { + let service: ZaloOaService; + let mockLogger: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + service = new ZaloOaService(mockLogger as any); + vi.restoreAllMocks(); + }); + + afterEach(() => { + delete process.env['ZALO_OA_ID']; + delete process.env['ZALO_OA_ACCESS_TOKEN']; + }); + + describe('onModuleInit', () => { + it('initializes when ZALO_OA_ID and ZALO_OA_ACCESS_TOKEN are set', () => { + process.env['ZALO_OA_ID'] = 'test-oa-id'; + process.env['ZALO_OA_ACCESS_TOKEN'] = 'test-access-token'; + + service.onModuleInit(); + + expect(service.isAvailable).toBe(true); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('test-oa-id'), + 'ZaloOaService', + ); + }); + + it('disables when ZALO_OA_ID is not set', () => { + process.env['ZALO_OA_ACCESS_TOKEN'] = 'test-access-token'; + + service.onModuleInit(); + + expect(service.isAvailable).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set'), + 'ZaloOaService', + ); + }); + + it('disables when ZALO_OA_ACCESS_TOKEN is not set', () => { + process.env['ZALO_OA_ID'] = 'test-oa-id'; + + service.onModuleInit(); + + expect(service.isAvailable).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set'), + 'ZaloOaService', + ); + }); + + it('disables when neither var is set', () => { + service.onModuleInit(); + + expect(service.isAvailable).toBe(false); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); + + describe('sendMessage', () => { + beforeEach(() => { + process.env['ZALO_OA_ID'] = 'test-oa-id'; + process.env['ZALO_OA_ACCESS_TOKEN'] = 'test-access-token'; + service.onModuleInit(); + }); + + it('sends a template message successfully', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + error: 0, + message: 'Success', + data: { msg_id: 'zalo-msg-123' }, + }), + text: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + const result = await service.sendMessage({ + toUid: '1234567890', + templateId: 'tpl-inquiry-001', + templateData: { buyer_name: 'Nguyễn Văn A', listing_title: 'Căn hộ Q7' }, + }); + + expect(result).toEqual({ messageId: 'zalo-msg-123' }); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://business.openapi.zalo.me/message/template', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + access_token: 'test-access-token', + }), + }), + ); + }); + + it('sends correct request body shape', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + error: 0, + data: { msg_id: 'zalo-msg-456' }, + }), + text: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + await service.sendMessage({ + toUid: '9876543210', + templateId: 'tpl-payment-001', + templateData: { amount: '50000000', payment_id: 'PAY-001' }, + }); + + const callBody = JSON.parse( + (globalThis.fetch as any).mock.calls[0][1].body, + ); + expect(callBody).toEqual({ + phone: '9876543210', + template_id: 'tpl-payment-001', + template_data: { amount: '50000000', payment_id: 'PAY-001' }, + }); + }); + + it('retries on failure with exponential backoff', async () => { + const mockFailResponse = { + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('Server error'), + }; + const mockSuccessResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + error: 0, + data: { msg_id: 'zalo-msg-retry' }, + }), + text: vi.fn(), + }; + + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(mockFailResponse as any) + .mockResolvedValueOnce(mockSuccessResponse as any); + + const result = await service.sendMessage({ + toUid: '1234567890', + templateId: 'tpl-001', + templateData: { key: 'value' }, + }); + + expect(result).toEqual({ messageId: 'zalo-msg-retry' }); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('attempt 1/3 failed'), + 'ZaloOaService', + ); + }); + + it('throws after 3 failed attempts', async () => { + const mockFailResponse = { + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('Server error'), + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockFailResponse as any); + + await expect( + service.sendMessage({ + toUid: '1234567890', + templateId: 'tpl-001', + templateData: { key: 'value' }, + }), + ).rejects.toThrow('Zalo OA API error (500)'); + + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('failed after 3 attempts'), + 'ZaloOaService', + ); + }); + + it('throws when Zalo returns non-zero error code', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + error: -201, + message: 'Invalid template', + }), + text: vi.fn(), + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + await expect( + service.sendMessage({ + toUid: '1234567890', + templateId: 'invalid-tpl', + templateData: {}, + }), + ).rejects.toThrow('Zalo OA message rejected'); + }); + + it('throws when not initialized', async () => { + const uninitService = new ZaloOaService(mockLogger as any); + + await expect( + uninitService.sendMessage({ + toUid: '1234567890', + templateId: 'tpl-001', + templateData: {}, + }), + ).rejects.toThrow('Zalo OA not initialized'); + }); + + it('generates a fallback message ID when API does not return one', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ error: 0, data: {} }), + text: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + const result = await service.sendMessage({ + toUid: '1234567890', + templateId: 'tpl-001', + templateData: {}, + }); + + expect(result.messageId).toMatch(/^zalo-oa-\d+$/); + }); + + it('masks recipient UID in log output', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + error: 0, + data: { msg_id: 'zalo-msg-mask' }, + }), + text: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any); + + await service.sendMessage({ + toUid: '1234567890', + templateId: 'tpl-001', + templateData: {}, + }); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('123456***'), + 'ZaloOaService', + ); + }); + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/services/stringee-sms.service.ts b/apps/api/src/modules/notifications/infrastructure/services/stringee-sms.service.ts new file mode 100644 index 0000000..95ca52b --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/services/stringee-sms.service.ts @@ -0,0 +1,152 @@ +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { type LoggerService } from '@modules/shared'; + +export interface SendSmsDto { + to: string; + message: string; +} + +export interface SendOtpDto { + to: string; + code: string; +} + +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1000; + +@Injectable() +export class StringeeSmsService implements OnModuleInit { + private apiKey = ''; + private brandName = ''; + private initialized = false; + private readonly baseUrl = 'https://api.stringee.com/v1/sms'; + + constructor(private readonly logger: LoggerService) {} + + onModuleInit(): void { + this.apiKey = process.env['STRINGEE_API_KEY'] ?? ''; + this.brandName = process.env['STRINGEE_BRANDNAME'] ?? 'GoodGo'; + + if (!this.apiKey) { + this.logger.warn( + 'STRINGEE_API_KEY not set — SMS notifications disabled', + 'StringeeSmsService', + ); + return; + } + + this.initialized = true; + this.logger.log( + `Stringee SMS configured with brandname "${this.brandName}"`, + 'StringeeSmsService', + ); + } + + get isAvailable(): boolean { + return this.initialized; + } + + async sendOTP(dto: SendOtpDto): Promise<{ messageId: string }> { + const message = `[${this.brandName}] Ma xac thuc cua ban la: ${dto.code}. Ma co hieu luc trong 5 phut.`; + return this.sendWithRetry({ to: dto.to, message }); + } + + async sendNotification(dto: SendSmsDto): Promise<{ messageId: string }> { + return this.sendWithRetry(dto); + } + + private async sendWithRetry(dto: SendSmsDto): Promise<{ messageId: string }> { + if (!this.initialized) { + throw new Error('Stringee SMS not initialized — STRINGEE_API_KEY not configured'); + } + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await this.send(dto); + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < MAX_RETRIES) { + const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); + this.logger.warn( + `Stringee SMS attempt ${attempt}/${MAX_RETRIES} failed: ${lastError.message}. Retrying in ${delayMs}ms...`, + 'StringeeSmsService', + ); + await this.delay(delayMs); + } + } + } + + this.logger.error( + `Stringee SMS failed after ${MAX_RETRIES} attempts: ${lastError?.message}`, + 'StringeeSmsService', + ); + throw lastError; + } + + private async send(dto: SendSmsDto): Promise<{ messageId: string }> { + const phone = this.normalizePhone(dto.to); + + const body = { + from: { type: 'sms', number: this.brandName, alias: this.brandName }, + to: [{ type: 'sms', number: phone }], + text: dto.message, + }; + + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-STRINGEE-AUTH': this.apiKey, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Stringee API error (${response.status}): ${errorText}`); + } + + const data = (await response.json()) as { message_id?: string; r?: number; message?: string }; + + // Stringee returns r=0 on success + if (data.r !== undefined && data.r !== 0) { + throw new Error(`Stringee SMS rejected (code ${data.r}): ${data.message ?? 'Unknown reason'}`); + } + + const messageId = data.message_id ?? `stringee-${Date.now()}`; + + this.logger.log( + `SMS sent to ${phone.slice(0, 6)}***: ${messageId}`, + 'StringeeSmsService', + ); + + return { messageId }; + } + + /** + * Normalize VN phone numbers to E.164 format (+84...). + * Accepts: 0901234567, +84901234567, 84901234567 + */ + private normalizePhone(phone: string): string { + const cleaned = phone.replace(/[\s\-()]/g, ''); + + if (cleaned.startsWith('+84')) { + return cleaned; + } + if (cleaned.startsWith('84') && cleaned.length >= 11) { + return `+${cleaned}`; + } + if (cleaned.startsWith('0')) { + return `+84${cleaned.slice(1)}`; + } + return cleaned; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/services/zalo-oa.service.ts b/apps/api/src/modules/notifications/infrastructure/services/zalo-oa.service.ts new file mode 100644 index 0000000..9d2daf4 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/services/zalo-oa.service.ts @@ -0,0 +1,149 @@ +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { type LoggerService } from '@modules/shared'; + +export interface SendZaloOaDto { + /** Zalo user ID (follower UID from OA) */ + toUid: string; + /** ZNS template ID registered in Zalo OA Manager */ + templateId: string; + /** Template parameter key-value pairs */ + templateData: Record; +} + +export interface ZaloOaMessageResult { + messageId: string; +} + +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1000; + +/** + * Service for sending template-based messages via Zalo Official Account (OA) API v3. + * + * Uses the Zalo Notification Service (ZNS) to deliver transactional messages + * such as new inquiry alerts, payment confirmations, and listing status changes. + * + * Requires ZALO_OA_ACCESS_TOKEN and ZALO_OA_ID to be configured. + */ +@Injectable() +export class ZaloOaService implements OnModuleInit { + private oaId = ''; + private accessToken = ''; + private initialized = false; + private readonly znsUrl = 'https://business.openapi.zalo.me/message/template'; + + constructor(private readonly logger: LoggerService) {} + + onModuleInit(): void { + this.oaId = process.env['ZALO_OA_ID'] ?? ''; + this.accessToken = process.env['ZALO_OA_ACCESS_TOKEN'] ?? ''; + + if (!this.oaId || !this.accessToken) { + this.logger.warn( + 'ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set — Zalo OA notifications disabled', + 'ZaloOaService', + ); + return; + } + + this.initialized = true; + this.logger.log( + `Zalo OA configured for OA ID "${this.oaId}"`, + 'ZaloOaService', + ); + } + + get isAvailable(): boolean { + return this.initialized; + } + + /** + * Send a template-based message to a Zalo user via ZNS (Zalo Notification Service). + * + * The user must be a follower of the Official Account, and the template must be + * pre-registered and approved in the Zalo OA Manager console. + */ + async sendMessage(dto: SendZaloOaDto): Promise { + return this.sendWithRetry(dto); + } + + private async sendWithRetry(dto: SendZaloOaDto): Promise { + if (!this.initialized) { + throw new Error('Zalo OA not initialized — ZALO_OA_ID / ZALO_OA_ACCESS_TOKEN not configured'); + } + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await this.send(dto); + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < MAX_RETRIES) { + const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); + this.logger.warn( + `Zalo OA attempt ${attempt}/${MAX_RETRIES} failed: ${lastError.message}. Retrying in ${delayMs}ms...`, + 'ZaloOaService', + ); + await this.delay(delayMs); + } + } + } + + this.logger.error( + `Zalo OA message failed after ${MAX_RETRIES} attempts: ${lastError?.message}`, + 'ZaloOaService', + ); + throw lastError; + } + + private async send(dto: SendZaloOaDto): Promise { + const body = { + phone: dto.toUid, + template_id: dto.templateId, + template_data: dto.templateData, + }; + + const response = await fetch(this.znsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + access_token: this.accessToken, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Zalo OA API error (${response.status}): ${errorText}`); + } + + const data = (await response.json()) as { + error?: number; + message?: string; + data?: { msg_id?: string }; + }; + + // Zalo API returns error=0 on success + if (data.error !== undefined && data.error !== 0) { + throw new Error( + `Zalo OA message rejected (code ${data.error}): ${data.message ?? 'Unknown reason'}`, + ); + } + + const messageId = data.data?.msg_id ?? `zalo-oa-${Date.now()}`; + + this.logger.log( + `Zalo OA message sent to ${dto.toUid.slice(0, 6)}***: ${messageId}`, + 'ZaloOaService', + ); + + return { messageId }; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/services/zalo-zns-templates.ts b/apps/api/src/modules/notifications/infrastructure/services/zalo-zns-templates.ts new file mode 100644 index 0000000..26d6bb1 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/services/zalo-zns-templates.ts @@ -0,0 +1,87 @@ +/** + * Zalo OA ZNS template configuration. + * + * Maps internal notification template keys to ZNS template IDs registered in + * the Zalo OA Manager console. Template IDs must be configured via environment + * variables (ZALO_ZNS_TEMPLATE_*). + * + * Template parameters must match the ZNS template definitions exactly — + * see the Zalo OA Manager for the approved parameter names. + */ + +export interface ZaloZnsTemplateConfig { + /** ZNS template ID (registered in Zalo OA Manager) */ + templateId: string; + /** Map our internal template data keys to ZNS parameter names */ + mapParams: (data: Record) => Record; +} + +/** + * Returns the ZNS template configurations, reading template IDs from environment. + * Returns only templates where the env var is configured. + */ +export function getZaloZnsTemplates(): Record { + const templates: Record = {}; + + // Inquiry received — notify property owner/agent about a new inquiry + const inquiryTplId = process.env['ZALO_ZNS_TEMPLATE_INQUIRY'] ?? ''; + if (inquiryTplId) { + templates['inquiry.received'] = { + templateId: inquiryTplId, + mapParams: (data) => ({ + customer_name: String(data['senderName'] ?? ''), + property_name: String(data['listingTitle'] ?? ''), + message: String(data['message'] ?? ''), + }), + }; + } + + // Payment confirmed — notify buyer about successful payment + const paymentTplId = process.env['ZALO_ZNS_TEMPLATE_PAYMENT'] ?? ''; + if (paymentTplId) { + templates['payment.confirmed'] = { + templateId: paymentTplId, + mapParams: (data) => ({ + payment_id: String(data['paymentId'] ?? ''), + amount: String(data['amountVND'] ?? ''), + payment_method: String(data['provider'] ?? ''), + }), + }; + } + + // Listing approved — notify owner that listing is live + const listingApprovedTplId = process.env['ZALO_ZNS_TEMPLATE_LISTING_APPROVED'] ?? ''; + if (listingApprovedTplId) { + templates['listing.approved'] = { + templateId: listingApprovedTplId, + mapParams: (data) => ({ + listing_title: String(data['listingTitle'] ?? ''), + }), + }; + } + + // Listing rejected — notify owner that listing was rejected + const listingRejectedTplId = process.env['ZALO_ZNS_TEMPLATE_LISTING_REJECTED'] ?? ''; + if (listingRejectedTplId) { + templates['listing.rejected'] = { + templateId: listingRejectedTplId, + mapParams: (data) => ({ + listing_title: String(data['listingTitle'] ?? ''), + reason: String(data['reason'] ?? ''), + }), + }; + } + + // Listing sold — notify owner that listing is sold + const listingSoldTplId = process.env['ZALO_ZNS_TEMPLATE_LISTING_SOLD'] ?? ''; + if (listingSoldTplId) { + templates['listing.sold'] = { + templateId: listingSoldTplId, + mapParams: (data) => ({ + listing_title: String(data['listingTitle'] ?? ''), + }), + }; + } + + return templates; +} diff --git a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts index ef5523c..b499898 100644 --- a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts +++ b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts @@ -13,14 +13,15 @@ import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiProperty } from '@nestjs/swagger'; import { NotificationChannel as PrismaChannel } from '@prisma/client'; import { IsBoolean, IsEnum, IsString } from 'class-validator'; -import { CurrentUser, JwtPayload } from '@modules/auth'; +import { CurrentUser, type JwtPayload } from '@modules/auth'; import { NOTIFICATION_REPOSITORY, - INotificationRepository, + type INotificationRepository, NOTIFICATION_PREFERENCE_REPOSITORY, - INotificationPreferenceRepository, + type INotificationPreferenceRepository, } from '../../domain'; -import { TemplateService } from '../../infrastructure/services/template.service'; +import { type TemplateService } from '../../infrastructure/services/template.service'; +import { type NotificationsGateway } from '../gateways/notifications.gateway'; class UpdatePreferenceDto { @ApiProperty({ enum: PrismaChannel, description: 'Notification channel' }) @@ -47,6 +48,7 @@ export class NotificationsController { @Inject(NOTIFICATION_PREFERENCE_REPOSITORY) private readonly preferenceRepo: INotificationPreferenceRepository, private readonly templateService: TemplateService, + private readonly notificationsGateway: NotificationsGateway, ) {} @Get('history') @@ -80,6 +82,15 @@ export class NotificationsController { return this.preferenceRepo.upsert(user.sub, dto.channel, dto.eventType, dto.enabled); } + @Get('unread-count') + @ApiOperation({ summary: 'Get unread notification count (Redis-cached)' }) + @ApiResponse({ status: 200, description: 'Unread count retrieved' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getUnreadCount(@CurrentUser() user: JwtPayload) { + const count = await this.notificationRepo.countUnreadByUserId(user.sub); + return { unreadCount: count }; + } + @Get('unread') @ApiOperation({ summary: 'Get unread notifications' }) @ApiResponse({ status: 200, description: 'Unread notifications retrieved' }) @@ -105,6 +116,9 @@ export class NotificationsController { @Param('id') id: string, ) { await this.notificationRepo.markAsRead(id, user.sub); + // Invalidate cached count and push updated count via WebSocket + await this.notificationsGateway.invalidateUnreadCount(user.sub); + await this.notificationsGateway.emitUnreadCount(user.sub); return { success: true }; } @@ -114,6 +128,9 @@ export class NotificationsController { @ApiResponse({ status: 401, description: 'Unauthorized' }) async markAllAsRead(@CurrentUser() user: JwtPayload) { const count = await this.notificationRepo.markAllAsRead(user.sub); + // Invalidate cached count and push updated count via WebSocket + await this.notificationsGateway.invalidateUnreadCount(user.sub); + await this.notificationsGateway.emitUnreadCount(user.sub); return { markedCount: count }; } diff --git a/apps/api/src/modules/notifications/presentation/gateways/notifications.gateway.ts b/apps/api/src/modules/notifications/presentation/gateways/notifications.gateway.ts new file mode 100644 index 0000000..6377dd6 --- /dev/null +++ b/apps/api/src/modules/notifications/presentation/gateways/notifications.gateway.ts @@ -0,0 +1,272 @@ +import { Inject } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { + WebSocketGateway, + WebSocketServer, + type OnGatewayConnection, + type OnGatewayDisconnect, + type OnGatewayInit, +} from '@nestjs/websockets'; +import type { Server, Socket } from 'socket.io'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports +import { TokenService, type JwtPayload } from '@modules/auth'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports +import { LoggerService, RedisService } from '@modules/shared'; +import type { NotificationSentEvent } from '../../domain/events/notification-sent.event'; +import { + NOTIFICATION_REPOSITORY, + type INotificationRepository, +} from '../../domain/repositories/notification.repository'; + +/** Redis key for the per-user unread notification counter. */ +const UNREAD_COUNT_KEY = (userId: string) => `notifications:unread:${userId}`; + +/** TTL for the cached unread count (1 hour). */ +const UNREAD_COUNT_TTL = 3600; + +@WebSocketGateway({ + namespace: '/notifications', + cors: { + origin: (process.env['CORS_ORIGINS'] ?? 'http://localhost:3000') + .split(',') + .map((o) => o.trim()), + credentials: true, + }, +}) +export class NotificationsGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server!: Server; + + /** Track connected sockets per user for multi-device support. */ + private readonly userSockets = new Map>(); + + constructor( + private readonly tokenService: TokenService, + private readonly logger: LoggerService, + private readonly redisService: RedisService, + @Inject(NOTIFICATION_REPOSITORY) + private readonly notificationRepo: INotificationRepository, + ) {} + + afterInit(): void { + this.logger.log('NotificationsGateway initialized', 'NotificationsGateway'); + } + + /* ──────────────────────────────────────────── + * Connection lifecycle + * ──────────────────────────────────────────── */ + + async handleConnection(client: Socket): Promise { + try { + const payload = this.extractAndVerifyToken(client); + if (!payload) { + client.disconnect(true); + return; + } + + // Attach identity to the socket for later use + client.data['userId'] = payload.sub; + client.data['role'] = payload.role; + + // Join the user's private room + await client.join(`user:${payload.sub}`); + + // Track socket for bookkeeping + if (!this.userSockets.has(payload.sub)) { + this.userSockets.set(payload.sub, new Set()); + } + this.userSockets.get(payload.sub)!.add(client.id); + + // Push the current unread count on connect + const unreadCount = await this.getUnreadCount(payload.sub); + client.emit('notification:unread-count', { unreadCount }); + + this.logger.debug( + `WS connected: user=${payload.sub} socket=${client.id}`, + 'NotificationsGateway', + ); + } catch (error) { + this.logger.error( + `WS connection error: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + 'NotificationsGateway', + ); + client.disconnect(true); + } + } + + handleDisconnect(client: Socket): void { + const userId = client.data['userId'] as string | undefined; + if (userId) { + const sockets = this.userSockets.get(userId); + if (sockets) { + sockets.delete(client.id); + if (sockets.size === 0) { + this.userSockets.delete(userId); + } + } + } + this.logger.debug( + `WS disconnected: user=${userId ?? 'unknown'} socket=${client.id}`, + 'NotificationsGateway', + ); + } + + /* ──────────────────────────────────────────── + * Domain event handlers + * ──────────────────────────────────────────── */ + + /** + * Listens to the `notification.sent` domain event emitted by + * {@link SendNotificationHandler} after a notification is persisted & sent. + * + * Pushes `notification:new` to the user's room and bumps the + * cached unread counter. + */ + @OnEvent('notification.sent', { async: true }) + async handleNotificationSent(event: NotificationSentEvent): Promise { + try { + this.server.to(`user:${event.userId}`).emit('notification:new', { + id: event.aggregateId, + templateKey: event.templateKey, + channel: event.channel, + occurredAt: event.occurredAt.toISOString(), + }); + + // Increment cached unread count + await this.incrementUnreadCount(event.userId); + + // Also emit updated count + const unreadCount = await this.getUnreadCount(event.userId); + this.server + .to(`user:${event.userId}`) + .emit('notification:unread-count', { unreadCount }); + } catch (error) { + this.logger.error( + `Failed to emit WS notification for user ${event.userId}: ${ + error instanceof Error ? error.message : error + }`, + error instanceof Error ? error.stack : undefined, + 'NotificationsGateway', + ); + } + } + + /* ──────────────────────────────────────────── + * Public helpers — used by the controller + * ──────────────────────────────────────────── */ + + /** + * Emit an updated unread count to a user after they mark + * notifications as read (called from the controller). + */ + async emitUnreadCount(userId: string): Promise { + const unreadCount = await this.getUnreadCount(userId); + this.server + .to(`user:${userId}`) + .emit('notification:unread-count', { unreadCount }); + } + + /** + * Invalidate the cached unread count (called after mark-as-read). + */ + async invalidateUnreadCount(userId: string): Promise { + if (this.redisService.isAvailable()) { + await this.redisService.del(UNREAD_COUNT_KEY(userId)); + } + } + + /* ──────────────────────────────────────────── + * Private helpers + * ──────────────────────────────────────────── */ + + /** + * Extract JWT from the socket handshake and verify it. + * + * Supports three sources (in priority order): + * 1. `handshake.auth.token` — Socket.IO `auth` option (recommended) + * 2. `handshake.headers.authorization` — HTTP upgrade header + * 3. `handshake.query.token` — query string (least secure) + */ + private extractAndVerifyToken(client: Socket): JwtPayload | null { + const raw: unknown = + client.handshake.auth?.['token'] ?? + client.handshake.headers?.['authorization'] ?? + client.handshake.query?.['token']; + + if (!raw || typeof raw !== 'string') { + this.logger.warn( + `WS auth failed: no token provided (socket=${client.id})`, + 'NotificationsGateway', + ); + return null; + } + + const token = raw.startsWith('Bearer ') ? raw.slice(7) : raw; + const payload = this.tokenService.verifyAccessToken(token); + if (!payload) { + this.logger.warn( + `WS auth failed: invalid token (socket=${client.id})`, + 'NotificationsGateway', + ); + } + return payload; + } + + /** + * Read the unread count from Redis (cache-aside pattern). + * Falls back to the database when Redis is unavailable or cache misses. + */ + private async getUnreadCount(userId: string): Promise { + if (this.redisService.isAvailable()) { + try { + const cached = await this.redisService.get(UNREAD_COUNT_KEY(userId)); + if (cached !== null) { + return Number(cached); + } + } catch { + // Redis unavailable — fall through to DB + } + } + + const count = await this.notificationRepo.countUnreadByUserId(userId); + + // Warm the cache + if (this.redisService.isAvailable()) { + try { + await this.redisService.set( + UNREAD_COUNT_KEY(userId), + String(count), + UNREAD_COUNT_TTL, + ); + } catch { + // Non-critical — continue without cache + } + } + + return count; + } + + /** + * Increment the cached unread counter in Redis (if available). + * The counter is lazily initialised from the DB on the next read if + * the key does not exist. + */ + private async incrementUnreadCount(userId: string): Promise { + if (!this.redisService.isAvailable()) return; + + try { + const client = this.redisService.getClient(); + const key = UNREAD_COUNT_KEY(userId); + const exists = await client.exists(key); + if (exists) { + await client.incr(key); + } + // If key doesn't exist, getUnreadCount will populate it on next read + } catch { + // Non-critical + } + } +} diff --git a/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.handler.ts b/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.handler.ts index c730561..a32de6d 100644 --- a/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.handler.ts +++ b/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.handler.ts @@ -1,17 +1,17 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; -import { PaymentStatus } from '@prisma/client'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { type PaymentStatus } from '@prisma/client'; import { DomainException, NotFoundException, ValidationException, - LoggerService, + type LoggerService, } from '@modules/shared'; import { PAYMENT_REPOSITORY, - IPaymentRepository, + type IPaymentRepository, } from '../../../domain/repositories/payment.repository'; -import { BankTransferService } from '../../../infrastructure/services/bank-transfer.service'; +import { type BankTransferService } from '../../../infrastructure/services/bank-transfer.service'; import { ConfirmBankTransferCommand } from './confirm-bank-transfer.command'; export interface ConfirmBankTransferResult { 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 f578387..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,14 +1,14 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; -import { PaymentStatus } from '@prisma/client'; -import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { type PaymentStatus } from '@prisma/client'; +import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared'; import { PAYMENT_REPOSITORY, - IPaymentRepository, + type IPaymentRepository, } from '../../../domain/repositories/payment.repository'; import { PAYMENT_GATEWAY_FACTORY, - IPaymentGatewayFactory, + type IPaymentGatewayFactory, } from '../../../infrastructure/services/payment-gateway.interface'; import { HandleCallbackCommand } from './handle-callback.command'; diff --git a/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts index ec62d8d..cc3bd93 100644 --- a/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts +++ b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts @@ -1,13 +1,13 @@ import { Injectable, BadRequestException } from '@nestjs/common'; -import { PaymentProvider } from '@prisma/client'; -import { BankTransferService } from './bank-transfer.service'; -import { MomoService } from './momo.service'; +import { type PaymentProvider } from '@prisma/client'; +import { type BankTransferService } from './bank-transfer.service'; +import { type MomoService } from './momo.service'; import { type IPaymentGateway, - IPaymentGatewayFactory, + type IPaymentGatewayFactory, } from './payment-gateway.interface'; -import { VnpayService } from './vnpay.service'; -import { ZalopayService } from './zalopay.service'; +import { type VnpayService } from './vnpay.service'; +import { type ZalopayService } from './zalopay.service'; @Injectable() export class PaymentGatewayFactory implements IPaymentGatewayFactory { diff --git a/apps/api/src/modules/payments/presentation/controllers/orders.controller.ts b/apps/api/src/modules/payments/presentation/controllers/orders.controller.ts index 0307041..d8a39f4 100644 --- a/apps/api/src/modules/payments/presentation/controllers/orders.controller.ts +++ b/apps/api/src/modules/payments/presentation/controllers/orders.controller.ts @@ -6,26 +6,26 @@ import { Post, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, } from '@nestjs/swagger'; -import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { CancelOrderCommand } from '../../application/commands/cancel-order/cancel-order.command'; -import { CancelOrderResult } from '../../application/commands/cancel-order/cancel-order.handler'; +import { type CancelOrderResult } from '../../application/commands/cancel-order/cancel-order.handler'; import { CreateOrderCommand } from '../../application/commands/create-order/create-order.command'; -import { CreateOrderResult } from '../../application/commands/create-order/create-order.handler'; +import { type CreateOrderResult } from '../../application/commands/create-order/create-order.handler'; import { HoldEscrowCommand } from '../../application/commands/hold-escrow/hold-escrow.command'; -import { HoldEscrowResult } from '../../application/commands/hold-escrow/hold-escrow.handler'; +import { type HoldEscrowResult } from '../../application/commands/hold-escrow/hold-escrow.handler'; import { ReleaseEscrowCommand } from '../../application/commands/release-escrow/release-escrow.command'; -import { ReleaseEscrowResult } from '../../application/commands/release-escrow/release-escrow.handler'; -import { OrderStatusDto } from '../../application/queries/get-order-status/get-order-status.handler'; +import { type ReleaseEscrowResult } from '../../application/commands/release-escrow/release-escrow.handler'; +import { type OrderStatusDto } from '../../application/queries/get-order-status/get-order-status.handler'; import { GetOrderStatusQuery } from '../../application/queries/get-order-status/get-order-status.query'; -import { CancelOrderDto } from '../dto/cancel-order.dto'; -import { CreateOrderDto } from '../dto/create-order.dto'; +import { type CancelOrderDto } from '../dto/cancel-order.dto'; +import { type CreateOrderDto } from '../dto/create-order.dto'; @ApiTags('orders') @Controller('orders') diff --git a/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts b/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts index e2130c8..27f9382 100644 --- a/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts +++ b/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts @@ -8,7 +8,7 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, @@ -17,25 +17,25 @@ import { ApiParam, } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; -import { PaymentProvider } from '@prisma/client'; -import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { type PaymentProvider } from '@prisma/client'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; import { ConfirmBankTransferCommand } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command'; -import { ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler'; +import { type ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler'; import { CreatePaymentCommand } from '../../application/commands/create-payment/create-payment.command'; -import { CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler'; +import { type CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler'; import { HandleCallbackCommand } from '../../application/commands/handle-callback/handle-callback.command'; -import { HandleCallbackResult } from '../../application/commands/handle-callback/handle-callback.handler'; +import { type HandleCallbackResult } from '../../application/commands/handle-callback/handle-callback.handler'; import { RefundPaymentCommand } from '../../application/commands/refund-payment/refund-payment.command'; -import { RefundPaymentResult } from '../../application/commands/refund-payment/refund-payment.handler'; -import { PaymentStatusDto } from '../../application/queries/get-payment-status/get-payment-status.handler'; +import { type RefundPaymentResult } from '../../application/commands/refund-payment/refund-payment.handler'; +import { type PaymentStatusDto } from '../../application/queries/get-payment-status/get-payment-status.handler'; import { GetPaymentStatusQuery } from '../../application/queries/get-payment-status/get-payment-status.query'; -import { TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler'; +import { type TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler'; import { ListTransactionsQuery } from '../../application/queries/list-transactions/list-transactions.query'; -import { ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto'; -import { CreatePaymentDto } from '../dto/create-payment.dto'; -import { ListTransactionsDto } from '../dto/list-transactions.dto'; -import { RefundPaymentDto } from '../dto/refund-payment.dto'; +import { type ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto'; +import { type CreatePaymentDto } from '../dto/create-payment.dto'; +import { type ListTransactionsDto } from '../dto/list-transactions.dto'; +import { type RefundPaymentDto } from '../dto/refund-payment.dto'; @ApiTags('payments') @Controller('payments') diff --git a/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts b/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts index 21c5ffc..1da9991 100644 --- a/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts +++ b/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts @@ -17,17 +17,17 @@ import { ApiBearerAuth, ApiParam, } from '@nestjs/swagger'; -import { JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth'; +import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth'; import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command'; -import { CreateReviewResult } from '../../application/commands/create-review/create-review.handler'; +import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler'; import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command'; import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query'; import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query'; import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query'; -import { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto'; -import { PaginatedResult } from '../../domain/repositories/review.repository'; -import { CreateReviewDto } from '../dto/create-review.dto'; -import { ListReviewsByTargetDto, ReviewStatsDto } from '../dto/list-reviews.dto'; +import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto'; +import { type PaginatedResult } from '../../domain/repositories/review.repository'; +import { type CreateReviewDto } from '../dto/create-review.dto'; +import { type ListReviewsByTargetDto, type ReviewStatsDto } from '../dto/list-reviews.dto'; @ApiTags('reviews') @Controller('reviews') diff --git a/apps/api/src/modules/search/infrastructure/services/resilient-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/resilient-search.repository.ts index 682dd37..dc96b90 100644 --- a/apps/api/src/modules/search/infrastructure/services/resilient-search.repository.ts +++ b/apps/api/src/modules/search/infrastructure/services/resilient-search.repository.ts @@ -1,20 +1,20 @@ import { Injectable } from '@nestjs/common'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; -import { Counter } from 'prom-client'; +import { type Counter } from 'prom-client'; import { CircuitBreaker, CircuitOpenError, type CircuitState, - LoggerService, + type LoggerService, } from '@modules/shared'; import { - ISearchRepository, + type ISearchRepository, type ListingDocument, type SearchParams, type SearchResult, } from '../../domain/repositories/search.repository'; -import { PostgresSearchRepository } from './postgres-search.repository'; -import { TypesenseSearchRepository } from './typesense-search.repository'; +import { type PostgresSearchRepository } from './postgres-search.repository'; +import { type TypesenseSearchRepository } from './typesense-search.repository'; export const SEARCH_DEGRADATION_TOTAL = 'search_degradation_total'; diff --git a/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts index 06fa398..de05e3a 100644 --- a/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts +++ b/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts @@ -1,14 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { Client as TypesenseClient } from 'typesense'; -import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; -import { LoggerService } from '@modules/shared'; +import { type Client as TypesenseClient } from 'typesense'; +import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; +import { type LoggerService } from '@modules/shared'; import { - ISearchRepository, + type ISearchRepository, type ListingDocument, type SearchParams, type SearchResult, } from '../../domain/repositories/search.repository'; -import { TypesenseClientService } from './typesense-client.service'; +import { type TypesenseClientService } from './typesense-client.service'; const COLLECTION_NAME = 'listings'; diff --git a/apps/api/src/modules/search/presentation/controllers/saved-search.controller.ts b/apps/api/src/modules/search/presentation/controllers/saved-search.controller.ts index 3b7131b..e623cc7 100644 --- a/apps/api/src/modules/search/presentation/controllers/saved-search.controller.ts +++ b/apps/api/src/modules/search/presentation/controllers/saved-search.controller.ts @@ -9,7 +9,7 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, @@ -17,17 +17,17 @@ import { ApiBearerAuth, ApiParam, } from '@nestjs/swagger'; -import { JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth'; +import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth'; import { CreateSavedSearchCommand } from '../../application/commands/create-saved-search/create-saved-search.command'; -import { CreateSavedSearchResult } from '../../application/commands/create-saved-search/create-saved-search.handler'; +import { type CreateSavedSearchResult } from '../../application/commands/create-saved-search/create-saved-search.handler'; import { DeleteSavedSearchCommand } from '../../application/commands/delete-saved-search/delete-saved-search.command'; import { UpdateSavedSearchCommand } from '../../application/commands/update-saved-search/update-saved-search.command'; -import { UpdateSavedSearchResult } from '../../application/commands/update-saved-search/update-saved-search.handler'; -import { SavedSearchDetail } from '../../application/queries/get-saved-search/get-saved-search.handler'; +import { type UpdateSavedSearchResult } from '../../application/commands/update-saved-search/update-saved-search.handler'; +import { type SavedSearchDetail } from '../../application/queries/get-saved-search/get-saved-search.handler'; import { GetSavedSearchQuery } from '../../application/queries/get-saved-search/get-saved-search.query'; -import { SavedSearchListResult } from '../../application/queries/get-saved-searches/get-saved-searches.handler'; +import { type SavedSearchListResult } from '../../application/queries/get-saved-searches/get-saved-searches.handler'; import { GetSavedSearchesQuery } from '../../application/queries/get-saved-searches/get-saved-searches.query'; -import { CreateSavedSearchDto, UpdateSavedSearchDto, SavedSearchListDto } from '../dto/saved-search.dto'; +import { type CreateSavedSearchDto, type UpdateSavedSearchDto, type SavedSearchListDto } from '../dto/saved-search.dto'; @ApiTags('saved-searches') @ApiBearerAuth('JWT') diff --git a/apps/api/src/modules/search/presentation/controllers/search.controller.ts b/apps/api/src/modules/search/presentation/controllers/search.controller.ts index 73de0cb..a679c03 100644 --- a/apps/api/src/modules/search/presentation/controllers/search.controller.ts +++ b/apps/api/src/modules/search/presentation/controllers/search.controller.ts @@ -5,7 +5,7 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, @@ -15,12 +15,12 @@ import { import { Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; import { ReindexAllCommand } from '../../application/commands/reindex-all/reindex-all.command'; -import { ReindexResult } from '../../application/commands/reindex-all/reindex-all.handler'; +import { type ReindexResult } from '../../application/commands/reindex-all/reindex-all.handler'; import { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query'; import { SearchPropertiesQuery } from '../../application/queries/search-properties/search-properties.query'; -import { SearchResult } from '../../domain/repositories/search.repository'; -import { GeoSearchDto } from '../dto/geo-search.dto'; -import { SearchPropertiesDto } from '../dto/search-properties.dto'; +import { type SearchResult } from '../../domain/repositories/search.repository'; +import { type GeoSearchDto } from '../dto/geo-search.dto'; +import { type SearchPropertiesDto } from '../dto/search-properties.dto'; @ApiTags('search') @Controller('search') diff --git a/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts b/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts index ff36cc3..ef368ae 100644 --- a/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts +++ b/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts @@ -9,7 +9,7 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, @@ -17,27 +17,27 @@ import { ApiBearerAuth, ApiParam, } from '@nestjs/swagger'; -import { PlanTier } from '@prisma/client'; -import { JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth'; +import { type PlanTier } from '@prisma/client'; +import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth'; import { CancelSubscriptionCommand } from '../../application/commands/cancel-subscription/cancel-subscription.command'; -import { CancelSubscriptionResult } from '../../application/commands/cancel-subscription/cancel-subscription.handler'; +import { type CancelSubscriptionResult } from '../../application/commands/cancel-subscription/cancel-subscription.handler'; import { CreateSubscriptionCommand } from '../../application/commands/create-subscription/create-subscription.command'; -import { CreateSubscriptionResult } from '../../application/commands/create-subscription/create-subscription.handler'; +import { type CreateSubscriptionResult } from '../../application/commands/create-subscription/create-subscription.handler'; import { MeterUsageCommand } from '../../application/commands/meter-usage/meter-usage.command'; -import { MeterUsageResult } from '../../application/commands/meter-usage/meter-usage.handler'; +import { type MeterUsageResult } from '../../application/commands/meter-usage/meter-usage.handler'; import { UpgradeSubscriptionCommand } from '../../application/commands/upgrade-subscription/upgrade-subscription.command'; -import { UpgradeSubscriptionResult } from '../../application/commands/upgrade-subscription/upgrade-subscription.handler'; -import { QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler'; +import { type UpgradeSubscriptionResult } from '../../application/commands/upgrade-subscription/upgrade-subscription.handler'; +import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler'; import { CheckQuotaQuery } from '../../application/queries/check-quota/check-quota.query'; -import { BillingHistoryDto } from '../../application/queries/get-billing-history/get-billing-history.handler'; +import { type BillingHistoryDto } from '../../application/queries/get-billing-history/get-billing-history.handler'; import { GetBillingHistoryQuery } from '../../application/queries/get-billing-history/get-billing-history.query'; -import { PlanDto } from '../../application/queries/get-plan/get-plan.handler'; +import { type PlanDto } from '../../application/queries/get-plan/get-plan.handler'; import { GetPlanQuery } from '../../application/queries/get-plan/get-plan.query'; -import { BillingHistoryParamsDto } from '../dto/billing-history.dto'; -import { CancelSubscriptionDto } from '../dto/cancel-subscription.dto'; -import { CreateSubscriptionDto } from '../dto/create-subscription.dto'; -import { MeterUsageDto } from '../dto/meter-usage.dto'; -import { UpgradeSubscriptionDto } from '../dto/upgrade-subscription.dto'; +import { type BillingHistoryParamsDto } from '../dto/billing-history.dto'; +import { type CancelSubscriptionDto } from '../dto/cancel-subscription.dto'; +import { type CreateSubscriptionDto } from '../dto/create-subscription.dto'; +import { type MeterUsageDto } from '../dto/meter-usage.dto'; +import { type UpgradeSubscriptionDto } from '../dto/upgrade-subscription.dto'; @ApiTags('subscriptions') @Controller('subscriptions') diff --git a/prisma/migrations/20260416200000_add_price_history/migration.sql b/prisma/migrations/20260416200000_add_price_history/migration.sql new file mode 100644 index 0000000..9c99a5c --- /dev/null +++ b/prisma/migrations/20260416200000_add_price_history/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable: PriceHistory (listing price change tracking) +CREATE TABLE "PriceHistory" ( + "id" TEXT NOT NULL, + "listingId" TEXT NOT NULL, + "oldPrice" BIGINT NOT NULL, + "newPrice" BIGINT NOT NULL, + "changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PriceHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PriceHistory_listingId_changedAt_idx" ON "PriceHistory"("listingId", "changedAt" DESC); + +-- AddForeignKey +ALTER TABLE "PriceHistory" ADD CONSTRAINT "PriceHistory_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e164936..fda4eb4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -333,9 +333,10 @@ model Listing { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - transactions Transaction[] - inquiries Inquiry[] - orders Order[] + transactions Transaction[] + inquiries Inquiry[] + orders Order[] + priceHistories PriceHistory[] // --- Single-column indexes --- @@index([status]) @@ -357,6 +358,17 @@ model Listing { @@index([status, transactionType, priceVND]) } +model PriceHistory { + id String @id @default(cuid()) + listingId String + listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) + oldPrice BigInt + newPrice BigInt + changedAt DateTime @default(now()) + + @@index([listingId, changedAt(sort: Desc)]) +} + // ============================================================================= // SEARCH // =============================================================================