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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 05:15:04 +07:00
parent c920934fb6
commit d4e100a00c
48 changed files with 1766 additions and 225 deletions

View File

@@ -24,10 +24,12 @@
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.1", "@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.7", "@nestjs/swagger": "^11.2.7",
"@nestjs/terminus": "^11.1.1", "@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.19",
"@paralleldrive/cuid2": "^3.3.0", "@paralleldrive/cuid2": "^3.3.0",
"@prisma/adapter-pg": "^7.7.0", "@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0", "@prisma/client": "^7.7.0",
@@ -56,6 +58,7 @@
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sanitize-html": "^2.17.2", "sanitize-html": "^2.17.2",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"typesense": "^3.0.5" "typesense": "^3.0.5"
}, },

View File

@@ -1,16 +1,16 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { LoggerService } from '@modules/shared'; import { type LoggerService } from '@modules/shared';
import { KycApprovedEvent } from '../../domain/events/kyc-approved.event'; import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
import { KycRejectedEvent } from '../../domain/events/kyc-rejected.event'; import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
import { ListingApprovedEvent } from '../../domain/events/listing-approved.event'; import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
import { ListingRejectedEvent } from '../../domain/events/listing-rejected.event'; import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
import { SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event'; import { type SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
import { UserBannedEvent } from '../../domain/events/user-banned.event'; import { type UserBannedEvent } from '../../domain/events/user-banned.event';
import { UserUnbannedEvent } from '../../domain/events/user-unbanned.event'; import { type UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
import { import {
AUDIT_LOG_REPOSITORY, AUDIT_LOG_REPOSITORY,
IAuditLogRepository, type IAuditLogRepository,
} from '../../domain/repositories/audit-log.repository'; } from '../../domain/repositories/audit-log.repository';
@Injectable() @Injectable()

View File

@@ -6,30 +6,30 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { 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 { 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 { 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 { 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 { 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 { 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 { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query'; import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
import { import {
type ModerationQueueResult, type ModerationQueueResult,
type KycQueueResult, type KycQueueResult,
} from '../../domain/repositories/admin-query.repository'; } from '../../domain/repositories/admin-query.repository';
import { ApproveKycDto } from '../dto/approve-kyc.dto'; import { type ApproveKycDto } from '../dto/approve-kyc.dto';
import { ApproveListingDto } from '../dto/approve-listing.dto'; import { type ApproveListingDto } from '../dto/approve-listing.dto';
import { BulkModerateDto } from '../dto/bulk-moderate.dto'; import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
import { RejectKycDto } from '../dto/reject-kyc.dto'; import { type RejectKycDto } from '../dto/reject-kyc.dto';
import { RejectListingDto } from '../dto/reject-listing.dto'; import { type RejectListingDto } from '../dto/reject-listing.dto';
@ApiTags('admin') @ApiTags('admin')
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')

View File

@@ -8,15 +8,15 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { 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 { 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 { 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 { 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 { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.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'; import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
@@ -28,13 +28,13 @@ import {
type UserListResult, type UserListResult,
type UserDetail, type UserDetail,
} from '../../domain/repositories/admin-query.repository'; } from '../../domain/repositories/admin-query.repository';
import { AuditLogListResult } from '../../domain/repositories/audit-log.repository'; import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto'; import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
import { BanUserDto } from '../dto/ban-user.dto'; import { type BanUserDto } from '../dto/ban-user.dto';
import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto'; import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
import { GetUsersQueryDto } from '../dto/get-users-query.dto'; import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
import { RevenueStatsDto } from '../dto/revenue-stats.dto'; import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
import { UpdateUserStatusDto } from '../dto/update-user-status.dto'; import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
@ApiTags('admin') @ApiTags('admin')
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')

View File

@@ -1,19 +1,19 @@
import { randomInt } from 'crypto';
import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { import {
CachePrefix, CachePrefix,
CacheService, CacheService,
ConflictException, ConflictException,
DomainException, DomainException,
LoggerService, type LoggerService,
NotFoundException, NotFoundException,
RedisService, type RedisService,
ValidationException, ValidationException,
} from '@modules/shared'; } 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 { 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'; import { UpdateProfileCommand } from './update-profile.command';
/** TTL for email-change OTP codes stored in Redis (10 minutes). */ /** TTL for email-change OTP codes stored in Redis (10 minutes). */

View File

@@ -1,13 +1,13 @@
import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService, UnauthorizedException } from '@modules/shared'; import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared';
import { import {
MFA_CHALLENGE_REPOSITORY, MFA_CHALLENGE_REPOSITORY,
IMfaChallengeRepository, type IMfaChallengeRepository,
} from '../../../domain/repositories/mfa-challenge.repository'; } from '../../../domain/repositories/mfa-challenge.repository';
import { USER_REPOSITORY, IUserRepository } from '../../../domain/repositories/user.repository'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { MfaService } from '../../../infrastructure/services/mfa.service'; import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { TokenService, TokenPair } from '../../../infrastructure/services/token.service'; import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { UseBackupCodeCommand } from './use-backup-code.command'; import { UseBackupCodeCommand } from './use-backup-code.command';
@CommandHandler(UseBackupCodeCommand) @CommandHandler(UseBackupCodeCommand)

View File

@@ -1,13 +1,13 @@
import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService, UnauthorizedException } from '@modules/shared'; import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared';
import { import {
MFA_CHALLENGE_REPOSITORY, MFA_CHALLENGE_REPOSITORY,
IMfaChallengeRepository, type IMfaChallengeRepository,
} from '../../../domain/repositories/mfa-challenge.repository'; } from '../../../domain/repositories/mfa-challenge.repository';
import { USER_REPOSITORY, IUserRepository } from '../../../domain/repositories/user.repository'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { MfaService } from '../../../infrastructure/services/mfa.service'; import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { TokenService, TokenPair } from '../../../infrastructure/services/token.service'; import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { VerifyMfaChallengeCommand } from './verify-mfa-challenge.command'; import { VerifyMfaChallengeCommand } from './verify-mfa-challenge.command';
@CommandHandler(VerifyMfaChallengeCommand) @CommandHandler(VerifyMfaChallengeCommand)

View File

@@ -1,14 +1,14 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs'; import { type EventBus } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { type OAuthProvider, Prisma } from '@prisma/client'; import { type OAuthProvider, type Prisma } from '@prisma/client';
import { PrismaService, LoggerService } from '@modules/shared'; import { type PrismaService, type LoggerService } from '@modules/shared';
import { UserEntity } from '../../domain/entities/user.entity'; import { UserEntity } from '../../domain/entities/user.entity';
import { UserRegisteredEvent } from '../../domain/events/user-registered.event'; 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 { Email } from '../../domain/value-objects/email.vo';
import { Phone } from '../../domain/value-objects/phone.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 { export interface OAuthUserProfile {
provider: OAuthProvider; provider: OAuthProvider;

View File

@@ -6,13 +6,10 @@ import {
Post, Post,
Req, Req,
Res, Res,
UploadedFiles,
UseGuards, UseGuards,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { type Request, type Response } from 'express'; import { type Request, type Response } from 'express';
import { import {
@@ -20,15 +17,12 @@ import {
EndpointRateLimitGuard, EndpointRateLimitGuard,
UnauthorizedException, UnauthorizedException,
ValidationException, ValidationException,
FileValidationPipe,
type UploadedFile as ValidatedFile,
} from '@modules/shared'; } 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 { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
import { type LoginResult } from '../../application/commands/login-user/login-user.handler'; import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command'; import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.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 { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command';
import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command'; import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command';
import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler'; 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 { LoginDto } from '../dto/login.dto';
import { type RefreshTokenDto } from '../dto/refresh-token.dto'; import { type RefreshTokenDto } from '../dto/refresh-token.dto';
import { type RegisterDto } from '../dto/register.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 { 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 { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { LocalAuthGuard } from '../guards/local-auth.guard'; import { LocalAuthGuard } from '../guards/local-auth.guard';
import { RolesGuard } from '../guards/roles.guard'; import { RolesGuard } from '../guards/roles.guard';

View File

@@ -7,21 +7,21 @@ import {
Res, Res,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { Response } from 'express'; import { type Response } from 'express';
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
import { DisableMfaCommand } from '../../application/commands/disable-mfa/disable-mfa.command'; import { DisableMfaCommand } from '../../application/commands/disable-mfa/disable-mfa.command';
import { SetupMfaCommand } from '../../application/commands/setup-mfa/setup-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 { UseBackupCodeCommand } from '../../application/commands/use-backup-code/use-backup-code.command';
import { VerifyMfaChallengeCommand } from '../../application/commands/verify-mfa-challenge/verify-mfa-challenge.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 { VerifyMfaSetupCommand } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.command';
import { VerifyMfaSetupResultDto } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.handler'; import { type 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 MfaStatusDto } from '../../application/queries/get-mfa-status/get-mfa-status.handler';
import { GetMfaStatusQuery } from '../../application/queries/get-mfa-status/get-mfa-status.query'; 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 { CurrentUser } from '../decorators/current-user.decorator';
import { import {
type VerifyMfaSetupDto, type VerifyMfaSetupDto,

View File

@@ -7,18 +7,18 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs'; import { type CommandBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command'; 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 { 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 { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command';
import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.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 { CurrentUser } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
import { ForceDeleteUserDto } from '../dto/force-delete-user.dto'; import { type ForceDeleteUserDto } from '../dto/force-delete-user.dto';
import { RequestDeletionDto } from '../dto/request-deletion.dto'; import { type RequestDeletionDto } from '../dto/request-deletion.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard'; import { RolesGuard } from '../guards/roles.guard';

View File

@@ -8,7 +8,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -16,16 +16,16 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
} from '@nestjs/swagger'; } 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 { 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 { 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 { 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 { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
import { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto'; import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
import { PaginatedResult } from '../../domain/repositories/inquiry.repository'; import { type PaginatedResult } from '../../domain/repositories/inquiry.repository';
import { CreateInquiryDto } from '../dto/create-inquiry.dto'; import { type CreateInquiryDto } from '../dto/create-inquiry.dto';
import { ListInquiriesDto } from '../dto/list-inquiries.dto'; import { type ListInquiriesDto } from '../dto/list-inquiries.dto';
@ApiTags('inquiries') @ApiTags('inquiries')
@Controller('inquiries') @Controller('inquiries')

View File

@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Lead as PrismaLead } from '@prisma/client'; import { type Lead as PrismaLead } from '@prisma/client';
import { PrismaService } from '@modules/shared'; import { type PrismaService } from '@modules/shared';
import { LeadEntity, LeadStatus } from '../../domain/entities/lead.entity'; import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity';
import { LeadReadDto } from '../../domain/repositories/lead-read.dto'; import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
import { ILeadRepository, LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository'; import { type ILeadRepository, type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
import { LeadScore } from '../../domain/value-objects/lead-score.vo'; import { LeadScore } from '../../domain/value-objects/lead-score.vo';
@Injectable() @Injectable()

View File

@@ -9,7 +9,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -17,18 +17,18 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
} from '@nestjs/swagger'; } 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 { 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 { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command';
import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.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 { 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 { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query';
import { LeadReadDto } from '../../domain/repositories/lead-read.dto'; import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
import { LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository'; import { type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
import { CreateLeadDto } from '../dto/create-lead.dto'; import { type CreateLeadDto } from '../dto/create-lead.dto';
import { ListLeadsDto } from '../dto/list-leads.dto'; import { type ListLeadsDto } from '../dto/list-leads.dto';
import { UpdateLeadStatusDto } from '../dto/update-lead-status.dto'; import { type UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
@ApiTags('leads') @ApiTags('leads')
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')

View File

@@ -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,
) {}
}

View File

@@ -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<FeaturePackage, bigint> = {
'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<FeatureListingCommand> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly commandBus: CommandBus,
private readonly logger: LoggerService,
) {}
async execute(command: FeatureListingCommand): Promise<FeatureListingResult> {
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');
}
}
}

View File

@@ -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<string, number> = {
'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<void> {
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',
);
}
}

View File

@@ -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<ListingPriceChangedEvent> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async handle(event: ListingPriceChangedEvent): Promise<void> {
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',
);
}
}
}

View File

@@ -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<GetPriceHistoryQuery> {
constructor(private readonly prisma: PrismaService) {}
async execute(query: GetPriceHistoryQuery): Promise<PriceHistoryItem[]> {
return this.prisma.priceHistory.findMany({
where: { listingId: query.listingId },
orderBy: { changedAt: 'desc' },
select: {
id: true,
oldPrice: true,
newPrice: true,
changedAt: true,
},
});
}
}

View File

@@ -0,0 +1,3 @@
export class GetPriceHistoryQuery {
constructor(public readonly listingId: string) {}
}

View File

@@ -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,
) {}
}

View File

@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Listing as PrismaListing, ListingStatus } from '@prisma/client'; import { type Listing as PrismaListing, type ListingStatus } from '@prisma/client';
import { PrismaService } from '@modules/shared'; import { type PrismaService } from '@modules/shared';
import { ListingEntity, ListingProps } from '../../domain/entities/listing.entity'; import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
import { ListingDetailData, ListingSearchItem, ListingSellerItem } from '../../domain/repositories/listing-read.dto'; import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { IListingRepository, ListingSearchParams, PaginatedResult } from '../../domain/repositories/listing.repository'; import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
import { Price } from '../../domain/value-objects/price.vo'; import { Price } from '../../domain/value-objects/price.vo';
import { findByIdWithProperty, searchListings, findBySellerIdQuery } from './listing-read.queries'; import { findByIdWithProperty, searchListings, findBySellerIdQuery } from './listing-read.queries';

View File

@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, PropertyMedia as PrismaMedia, PropertyType, Direction } from '@prisma/client'; import { type Prisma, type PropertyMedia as PrismaMedia, type PropertyType, type Direction } from '@prisma/client';
import { PrismaService } from '@modules/shared'; import { type PrismaService } from '@modules/shared';
import { PropertyMediaEntity, PropertyMediaProps } from '../../domain/entities/property-media.entity'; import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity';
import { PropertyEntity, PropertyProps } from '../../domain/entities/property.entity'; import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity';
import { IPropertyRepository } from '../../domain/repositories/property.repository'; import { type IPropertyRepository } from '../../domain/repositories/property.repository';
import { Address } from '../../domain/value-objects/address.vo'; import { Address } from '../../domain/value-objects/address.vo';
import { GeoPoint } from '../../domain/value-objects/geo-point.vo'; import { GeoPoint } from '../../domain/value-objects/geo-point.vo';

View File

@@ -2,6 +2,7 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
Ip,
Param, Param,
Patch, Patch,
Post, Post,
@@ -29,6 +30,8 @@ import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValid
import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command'; import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
import type { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; 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 { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command'; import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler'; import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';

View File

@@ -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;
}

View File

@@ -1,4 +1,5 @@
export { CreateListingDto } from './create-listing.dto'; export { CreateListingDto } from './create-listing.dto';
export { FeatureListingDto } from './feature-listing.dto';
export { UpdateListingDto } from './update-listing.dto'; export { UpdateListingDto } from './update-listing.dto';
export { UpdateListingStatusDto } from './update-listing-status.dto'; export { UpdateListingStatusDto } from './update-listing-status.dto';
export { ModerateListingDto } from './moderate-listing.dto'; export { ModerateListingDto } from './moderate-listing.dto';

View File

@@ -15,6 +15,8 @@ describe('SendNotificationHandler', () => {
}; };
let mockEmailService: { send: ReturnType<typeof vi.fn> }; let mockEmailService: { send: ReturnType<typeof vi.fn> };
let mockFcmService: { send: ReturnType<typeof vi.fn> }; let mockFcmService: { send: ReturnType<typeof vi.fn> };
let mockStringeeSmsService: { sendNotification: ReturnType<typeof vi.fn>; isAvailable: boolean };
let mockZaloOaService: { sendMessage: ReturnType<typeof vi.fn>; isAvailable: boolean };
let mockTemplateService: { render: ReturnType<typeof vi.fn> }; let mockTemplateService: { render: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> }; let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> }; let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
@@ -46,6 +48,14 @@ describe('SendNotificationHandler', () => {
}; };
mockEmailService = { send: vi.fn().mockResolvedValue({ messageId: 'msg-1' }) }; mockEmailService = { send: vi.fn().mockResolvedValue({ messageId: 'msg-1' }) };
mockFcmService = { send: vi.fn().mockResolvedValue('fcm-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 = { mockTemplateService = {
render: vi.fn().mockReturnValue({ subject: 'Chào mừng!', body: '<p>Chào mừng!</p>' }), render: vi.fn().mockReturnValue({ subject: 'Chào mừng!', body: '<p>Chào mừng!</p>' }),
}; };
@@ -57,6 +67,8 @@ describe('SendNotificationHandler', () => {
mockPreferenceRepo as any, mockPreferenceRepo as any,
mockEmailService as any, mockEmailService as any,
mockFcmService as any, mockFcmService as any,
mockStringeeSmsService as any,
mockZaloOaService as any,
mockTemplateService as any, mockTemplateService as any,
mockEventBus as any, mockEventBus as any,
mockLogger as any, mockLogger as any,
@@ -97,6 +109,52 @@ describe('SendNotificationHandler', () => {
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT'); 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 () => { it('skips notification when user preference is disabled', async () => {
mockPreferenceRepo.isEnabled.mockResolvedValue(false); mockPreferenceRepo.isEnabled.mockResolvedValue(false);
@@ -111,28 +169,19 @@ describe('SendNotificationHandler', () => {
expect(mockLogger.log).toHaveBeenCalled(); expect(mockLogger.log).toHaveBeenCalled();
}); });
it('logs pending status for unimplemented channels (SMS)', async () => { it('logs pending when Zalo OA is not available', 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 () => {
const command = new SendNotificationCommand( const command = new SendNotificationCommand(
'user-1', 'ZALO_OA', 'user.registered', {}, 'zalo-id', 'user-1', 'ZALO_OA', 'user.registered', {}, 'zalo-id',
); );
await handler.execute(command); 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(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING');
expect(mockZaloOaService.sendMessage).not.toHaveBeenCalled();
}); });
it('marks notification as FAILED when email send throws', async () => { it('marks notification as FAILED when email send throws', async () => {

View File

@@ -1,18 +1,21 @@
import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, EventBusService, LoggerService } from '@modules/shared'; import { DomainException, type EventBusService, type LoggerService } from '@modules/shared';
import { NotificationSentEvent } from '../../../domain/events/notification-sent.event'; import { NotificationSentEvent } from '../../../domain/events/notification-sent.event';
import { import {
NOTIFICATION_PREFERENCE_REPOSITORY, NOTIFICATION_PREFERENCE_REPOSITORY,
INotificationPreferenceRepository, type INotificationPreferenceRepository,
} from '../../../domain/repositories/notification-preference.repository'; } from '../../../domain/repositories/notification-preference.repository';
import { import {
NOTIFICATION_REPOSITORY, NOTIFICATION_REPOSITORY,
INotificationRepository, type INotificationRepository,
} from '../../../domain/repositories/notification.repository'; } from '../../../domain/repositories/notification.repository';
import { EmailService } from '../../../infrastructure/services/email.service'; import { type EmailService } from '../../../infrastructure/services/email.service';
import { FcmService } from '../../../infrastructure/services/fcm.service'; import { type FcmService } from '../../../infrastructure/services/fcm.service';
import { TemplateService } from '../../../infrastructure/services/template.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'; import { SendNotificationCommand } from './send-notification.command';
@CommandHandler(SendNotificationCommand) @CommandHandler(SendNotificationCommand)
@@ -24,6 +27,8 @@ export class SendNotificationHandler implements ICommandHandler<SendNotification
private readonly preferenceRepo: INotificationPreferenceRepository, private readonly preferenceRepo: INotificationPreferenceRepository,
private readonly emailService: EmailService, private readonly emailService: EmailService,
private readonly fcmService: FcmService, private readonly fcmService: FcmService,
private readonly stringeeSmsService: StringeeSmsService,
private readonly zaloOaService: ZaloOaService,
private readonly templateService: TemplateService, private readonly templateService: TemplateService,
private readonly eventBus: EventBusService, private readonly eventBus: EventBusService,
private readonly logger: LoggerService, private readonly logger: LoggerService,
@@ -75,14 +80,49 @@ export class SendNotificationHandler implements ICommandHandler<SendNotification
break; break;
case 'SMS': case 'SMS':
case 'ZALO_OA': if (!this.stringeeSmsService.isAvailable) {
// Placeholder — these channels will be implemented when providers are integrated this.logger.warn(
this.logger.warn( 'SMS channel requested but Stringee is not configured — notification logged as PENDING',
`Channel ${channel} not yet implemented — notification logged but not sent`, 'SendNotificationHandler',
'SendNotificationHandler', );
); await this.notificationRepo.updateStatus(notification.id, 'PENDING');
await this.notificationRepo.updateStatus(notification.id, 'PENDING'); return;
return; }
await this.stringeeSmsService.sendNotification({
to: recipientAddress,
message: rendered.body.replace(/<[^>]*>/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'); await this.notificationRepo.updateStatus(notification.id, 'SENT');

View File

@@ -0,0 +1,221 @@
import { StringeeSmsService } from '../services/stringee-sms.service';
describe('StringeeSmsService', () => {
let service: StringeeSmsService;
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
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');
});
});
});

View File

@@ -0,0 +1,262 @@
import { ZaloOaService } from '../services/zalo-oa.service';
describe('ZaloOaService', () => {
let service: ZaloOaService;
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
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',
);
});
});
});

View File

@@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -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<string, string>;
}
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<ZaloOaMessageResult> {
return this.sendWithRetry(dto);
}
private async sendWithRetry(dto: SendZaloOaDto): Promise<ZaloOaMessageResult> {
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<ZaloOaMessageResult> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -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<string, unknown>) => Record<string, string>;
}
/**
* Returns the ZNS template configurations, reading template IDs from environment.
* Returns only templates where the env var is configured.
*/
export function getZaloZnsTemplates(): Record<string, ZaloZnsTemplateConfig> {
const templates: Record<string, ZaloZnsTemplateConfig> = {};
// 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;
}

View File

@@ -13,14 +13,15 @@ import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiProperty } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiProperty } from '@nestjs/swagger';
import { NotificationChannel as PrismaChannel } from '@prisma/client'; import { NotificationChannel as PrismaChannel } from '@prisma/client';
import { IsBoolean, IsEnum, IsString } from 'class-validator'; import { IsBoolean, IsEnum, IsString } from 'class-validator';
import { CurrentUser, JwtPayload } from '@modules/auth'; import { CurrentUser, type JwtPayload } from '@modules/auth';
import { import {
NOTIFICATION_REPOSITORY, NOTIFICATION_REPOSITORY,
INotificationRepository, type INotificationRepository,
NOTIFICATION_PREFERENCE_REPOSITORY, NOTIFICATION_PREFERENCE_REPOSITORY,
INotificationPreferenceRepository, type INotificationPreferenceRepository,
} from '../../domain'; } 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 { class UpdatePreferenceDto {
@ApiProperty({ enum: PrismaChannel, description: 'Notification channel' }) @ApiProperty({ enum: PrismaChannel, description: 'Notification channel' })
@@ -47,6 +48,7 @@ export class NotificationsController {
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY) @Inject(NOTIFICATION_PREFERENCE_REPOSITORY)
private readonly preferenceRepo: INotificationPreferenceRepository, private readonly preferenceRepo: INotificationPreferenceRepository,
private readonly templateService: TemplateService, private readonly templateService: TemplateService,
private readonly notificationsGateway: NotificationsGateway,
) {} ) {}
@Get('history') @Get('history')
@@ -80,6 +82,15 @@ export class NotificationsController {
return this.preferenceRepo.upsert(user.sub, dto.channel, dto.eventType, dto.enabled); 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') @Get('unread')
@ApiOperation({ summary: 'Get unread notifications' }) @ApiOperation({ summary: 'Get unread notifications' })
@ApiResponse({ status: 200, description: 'Unread notifications retrieved' }) @ApiResponse({ status: 200, description: 'Unread notifications retrieved' })
@@ -105,6 +116,9 @@ export class NotificationsController {
@Param('id') id: string, @Param('id') id: string,
) { ) {
await this.notificationRepo.markAsRead(id, user.sub); 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 }; return { success: true };
} }
@@ -114,6 +128,9 @@ export class NotificationsController {
@ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 401, description: 'Unauthorized' })
async markAllAsRead(@CurrentUser() user: JwtPayload) { async markAllAsRead(@CurrentUser() user: JwtPayload) {
const count = await this.notificationRepo.markAllAsRead(user.sub); 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 }; return { markedCount: count };
} }

View File

@@ -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<string, Set<string>>();
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<void> {
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<void> {
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<void> {
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<void> {
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<number> {
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<void> {
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
}
}
}

View File

@@ -1,17 +1,17 @@
import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { PaymentStatus } from '@prisma/client'; import { type PaymentStatus } from '@prisma/client';
import { import {
DomainException, DomainException,
NotFoundException, NotFoundException,
ValidationException, ValidationException,
LoggerService, type LoggerService,
} from '@modules/shared'; } from '@modules/shared';
import { import {
PAYMENT_REPOSITORY, PAYMENT_REPOSITORY,
IPaymentRepository, type IPaymentRepository,
} from '../../../domain/repositories/payment.repository'; } 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'; import { ConfirmBankTransferCommand } from './confirm-bank-transfer.command';
export interface ConfirmBankTransferResult { export interface ConfirmBankTransferResult {

View File

@@ -1,14 +1,14 @@
import { Inject, InternalServerErrorException } from '@nestjs/common'; import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { PaymentStatus } from '@prisma/client'; import { type PaymentStatus } from '@prisma/client';
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared'; import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import { import {
PAYMENT_REPOSITORY, PAYMENT_REPOSITORY,
IPaymentRepository, type IPaymentRepository,
} from '../../../domain/repositories/payment.repository'; } from '../../../domain/repositories/payment.repository';
import { import {
PAYMENT_GATEWAY_FACTORY, PAYMENT_GATEWAY_FACTORY,
IPaymentGatewayFactory, type IPaymentGatewayFactory,
} from '../../../infrastructure/services/payment-gateway.interface'; } from '../../../infrastructure/services/payment-gateway.interface';
import { HandleCallbackCommand } from './handle-callback.command'; import { HandleCallbackCommand } from './handle-callback.command';

View File

@@ -1,13 +1,13 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException } from '@nestjs/common';
import { PaymentProvider } from '@prisma/client'; import { type PaymentProvider } from '@prisma/client';
import { BankTransferService } from './bank-transfer.service'; import { type BankTransferService } from './bank-transfer.service';
import { MomoService } from './momo.service'; import { type MomoService } from './momo.service';
import { import {
type IPaymentGateway, type IPaymentGateway,
IPaymentGatewayFactory, type IPaymentGatewayFactory,
} from './payment-gateway.interface'; } from './payment-gateway.interface';
import { VnpayService } from './vnpay.service'; import { type VnpayService } from './vnpay.service';
import { ZalopayService } from './zalopay.service'; import { type ZalopayService } from './zalopay.service';
@Injectable() @Injectable()
export class PaymentGatewayFactory implements IPaymentGatewayFactory { export class PaymentGatewayFactory implements IPaymentGatewayFactory {

View File

@@ -6,26 +6,26 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
} from '@nestjs/swagger'; } 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 { 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 { 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 { 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 { ReleaseEscrowCommand } from '../../application/commands/release-escrow/release-escrow.command';
import { ReleaseEscrowResult } from '../../application/commands/release-escrow/release-escrow.handler'; import { type ReleaseEscrowResult } from '../../application/commands/release-escrow/release-escrow.handler';
import { OrderStatusDto } from '../../application/queries/get-order-status/get-order-status.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 { GetOrderStatusQuery } from '../../application/queries/get-order-status/get-order-status.query';
import { CancelOrderDto } from '../dto/cancel-order.dto'; import { type CancelOrderDto } from '../dto/cancel-order.dto';
import { CreateOrderDto } from '../dto/create-order.dto'; import { type CreateOrderDto } from '../dto/create-order.dto';
@ApiTags('orders') @ApiTags('orders')
@Controller('orders') @Controller('orders')

View File

@@ -8,7 +8,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -17,25 +17,25 @@ import {
ApiParam, ApiParam,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { PaymentProvider } from '@prisma/client'; import { type PaymentProvider } from '@prisma/client';
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
import { ConfirmBankTransferCommand } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command'; 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 { 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 { 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 { RefundPaymentCommand } from '../../application/commands/refund-payment/refund-payment.command';
import { RefundPaymentResult } from '../../application/commands/refund-payment/refund-payment.handler'; import { type RefundPaymentResult } from '../../application/commands/refund-payment/refund-payment.handler';
import { PaymentStatusDto } from '../../application/queries/get-payment-status/get-payment-status.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 { 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 { ListTransactionsQuery } from '../../application/queries/list-transactions/list-transactions.query';
import { ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto'; import { type ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto';
import { CreatePaymentDto } from '../dto/create-payment.dto'; import { type CreatePaymentDto } from '../dto/create-payment.dto';
import { ListTransactionsDto } from '../dto/list-transactions.dto'; import { type ListTransactionsDto } from '../dto/list-transactions.dto';
import { RefundPaymentDto } from '../dto/refund-payment.dto'; import { type RefundPaymentDto } from '../dto/refund-payment.dto';
@ApiTags('payments') @ApiTags('payments')
@Controller('payments') @Controller('payments')

View File

@@ -17,17 +17,17 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
} from '@nestjs/swagger'; } 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 { 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 { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query'; 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 { 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 { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
import { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto'; import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
import { PaginatedResult } from '../../domain/repositories/review.repository'; import { type PaginatedResult } from '../../domain/repositories/review.repository';
import { CreateReviewDto } from '../dto/create-review.dto'; import { type CreateReviewDto } from '../dto/create-review.dto';
import { ListReviewsByTargetDto, ReviewStatsDto } from '../dto/list-reviews.dto'; import { type ListReviewsByTargetDto, type ReviewStatsDto } from '../dto/list-reviews.dto';
@ApiTags('reviews') @ApiTags('reviews')
@Controller('reviews') @Controller('reviews')

View File

@@ -1,20 +1,20 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus'; import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Counter } from 'prom-client'; import { type Counter } from 'prom-client';
import { import {
CircuitBreaker, CircuitBreaker,
CircuitOpenError, CircuitOpenError,
type CircuitState, type CircuitState,
LoggerService, type LoggerService,
} from '@modules/shared'; } from '@modules/shared';
import { import {
ISearchRepository, type ISearchRepository,
type ListingDocument, type ListingDocument,
type SearchParams, type SearchParams,
type SearchResult, type SearchResult,
} from '../../domain/repositories/search.repository'; } from '../../domain/repositories/search.repository';
import { PostgresSearchRepository } from './postgres-search.repository'; import { type PostgresSearchRepository } from './postgres-search.repository';
import { TypesenseSearchRepository } from './typesense-search.repository'; import { type TypesenseSearchRepository } from './typesense-search.repository';
export const SEARCH_DEGRADATION_TOTAL = 'search_degradation_total'; export const SEARCH_DEGRADATION_TOTAL = 'search_degradation_total';

View File

@@ -1,14 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Client as TypesenseClient } from 'typesense'; import { type Client as TypesenseClient } from 'typesense';
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { LoggerService } from '@modules/shared'; import { type LoggerService } from '@modules/shared';
import { import {
ISearchRepository, type ISearchRepository,
type ListingDocument, type ListingDocument,
type SearchParams, type SearchParams,
type SearchResult, type SearchResult,
} from '../../domain/repositories/search.repository'; } from '../../domain/repositories/search.repository';
import { TypesenseClientService } from './typesense-client.service'; import { type TypesenseClientService } from './typesense-client.service';
const COLLECTION_NAME = 'listings'; const COLLECTION_NAME = 'listings';

View File

@@ -9,7 +9,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -17,17 +17,17 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
} from '@nestjs/swagger'; } 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 { 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 { DeleteSavedSearchCommand } from '../../application/commands/delete-saved-search/delete-saved-search.command';
import { UpdateSavedSearchCommand } from '../../application/commands/update-saved-search/update-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 { type 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 SavedSearchDetail } from '../../application/queries/get-saved-search/get-saved-search.handler';
import { GetSavedSearchQuery } from '../../application/queries/get-saved-search/get-saved-search.query'; 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 { 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') @ApiTags('saved-searches')
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')

View File

@@ -5,7 +5,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -15,12 +15,12 @@ import {
import { Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; import { Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
import { ReindexAllCommand } from '../../application/commands/reindex-all/reindex-all.command'; 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 { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query';
import { SearchPropertiesQuery } from '../../application/queries/search-properties/search-properties.query'; import { SearchPropertiesQuery } from '../../application/queries/search-properties/search-properties.query';
import { SearchResult } from '../../domain/repositories/search.repository'; import { type SearchResult } from '../../domain/repositories/search.repository';
import { GeoSearchDto } from '../dto/geo-search.dto'; import { type GeoSearchDto } from '../dto/geo-search.dto';
import { SearchPropertiesDto } from '../dto/search-properties.dto'; import { type SearchPropertiesDto } from '../dto/search-properties.dto';
@ApiTags('search') @ApiTags('search')
@Controller('search') @Controller('search')

View File

@@ -9,7 +9,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -17,27 +17,27 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { PlanTier } from '@prisma/client'; import { type PlanTier } from '@prisma/client';
import { JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth'; import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
import { CancelSubscriptionCommand } from '../../application/commands/cancel-subscription/cancel-subscription.command'; 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 { 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 { 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 { UpgradeSubscriptionCommand } from '../../application/commands/upgrade-subscription/upgrade-subscription.command';
import { UpgradeSubscriptionResult } from '../../application/commands/upgrade-subscription/upgrade-subscription.handler'; import { type UpgradeSubscriptionResult } from '../../application/commands/upgrade-subscription/upgrade-subscription.handler';
import { QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler'; import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler';
import { CheckQuotaQuery } from '../../application/queries/check-quota/check-quota.query'; 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 { 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 { GetPlanQuery } from '../../application/queries/get-plan/get-plan.query';
import { BillingHistoryParamsDto } from '../dto/billing-history.dto'; import { type BillingHistoryParamsDto } from '../dto/billing-history.dto';
import { CancelSubscriptionDto } from '../dto/cancel-subscription.dto'; import { type CancelSubscriptionDto } from '../dto/cancel-subscription.dto';
import { CreateSubscriptionDto } from '../dto/create-subscription.dto'; import { type CreateSubscriptionDto } from '../dto/create-subscription.dto';
import { MeterUsageDto } from '../dto/meter-usage.dto'; import { type MeterUsageDto } from '../dto/meter-usage.dto';
import { UpgradeSubscriptionDto } from '../dto/upgrade-subscription.dto'; import { type UpgradeSubscriptionDto } from '../dto/upgrade-subscription.dto';
@ApiTags('subscriptions') @ApiTags('subscriptions')
@Controller('subscriptions') @Controller('subscriptions')

View File

@@ -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;

View File

@@ -333,9 +333,10 @@ model Listing {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
transactions Transaction[] transactions Transaction[]
inquiries Inquiry[] inquiries Inquiry[]
orders Order[] orders Order[]
priceHistories PriceHistory[]
// --- Single-column indexes --- // --- Single-column indexes ---
@@index([status]) @@index([status])
@@ -357,6 +358,17 @@ model Listing {
@@index([status, transactionType, priceVND]) @@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 // SEARCH
// ============================================================================= // =============================================================================