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:
@@ -1,16 +1,16 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
||||
import { KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
||||
import { ListingApprovedEvent } from '../../domain/events/listing-approved.event';
|
||||
import { ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
|
||||
import { SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
|
||||
import { UserBannedEvent } from '../../domain/events/user-banned.event';
|
||||
import { UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
|
||||
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
|
||||
import { type ListingApprovedEvent } from '../../domain/events/listing-approved.event';
|
||||
import { type ListingRejectedEvent } from '../../domain/events/listing-rejected.event';
|
||||
import { type SubscriptionAdjustedEvent } from '../../domain/events/subscription-adjusted.event';
|
||||
import { type UserBannedEvent } from '../../domain/events/user-banned.event';
|
||||
import { type UserUnbannedEvent } from '../../domain/events/user-unbanned.event';
|
||||
import {
|
||||
AUDIT_LOG_REPOSITORY,
|
||||
IAuditLogRepository,
|
||||
type IAuditLogRepository,
|
||||
} from '../../domain/repositories/audit-log.repository';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -6,30 +6,30 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
|
||||
import { ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
|
||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
||||
import { ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
|
||||
import { type ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
|
||||
import { BulkModerateListingsCommand } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.command';
|
||||
import { BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
|
||||
import { type BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
|
||||
import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-kyc.command';
|
||||
import { RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
||||
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
|
||||
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
||||
import { RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
||||
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
||||
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
|
||||
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
||||
import {
|
||||
type ModerationQueueResult,
|
||||
type KycQueueResult,
|
||||
} from '../../domain/repositories/admin-query.repository';
|
||||
import { ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||
import { ApproveListingDto } from '../dto/approve-listing.dto';
|
||||
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||
import { RejectKycDto } from '../dto/reject-kyc.dto';
|
||||
import { RejectListingDto } from '../dto/reject-listing.dto';
|
||||
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
|
||||
import { type ApproveListingDto } from '../dto/approve-listing.dto';
|
||||
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||
import { type RejectKycDto } from '../dto/reject-kyc.dto';
|
||||
import { type RejectListingDto } from '../dto/reject-listing.dto';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth('JWT')
|
||||
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
||||
import { AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
||||
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
||||
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
|
||||
import { BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
|
||||
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
|
||||
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
|
||||
import { UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
||||
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
|
||||
import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query';
|
||||
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
||||
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
||||
@@ -28,13 +28,13 @@ import {
|
||||
type UserListResult,
|
||||
type UserDetail,
|
||||
} from '../../domain/repositories/admin-query.repository';
|
||||
import { AuditLogListResult } from '../../domain/repositories/audit-log.repository';
|
||||
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
||||
import { BanUserDto } from '../dto/ban-user.dto';
|
||||
import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
||||
import { GetUsersQueryDto } from '../dto/get-users-query.dto';
|
||||
import { RevenueStatsDto } from '../dto/revenue-stats.dto';
|
||||
import { UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
||||
import { type AuditLogListResult } from '../../domain/repositories/audit-log.repository';
|
||||
import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
||||
import { type BanUserDto } from '../dto/ban-user.dto';
|
||||
import { type GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
|
||||
import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
|
||||
import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
|
||||
import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth('JWT')
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { randomInt } from 'crypto';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CachePrefix,
|
||||
CacheService,
|
||||
ConflictException,
|
||||
DomainException,
|
||||
LoggerService,
|
||||
type LoggerService,
|
||||
NotFoundException,
|
||||
RedisService,
|
||||
type RedisService,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { randomInt } from 'crypto';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { UpdateProfileCommand } from './update-profile.command';
|
||||
|
||||
/** TTL for email-change OTP codes stored in Redis (10 minutes). */
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService, UnauthorizedException } from '@modules/shared';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared';
|
||||
import {
|
||||
MFA_CHALLENGE_REPOSITORY,
|
||||
IMfaChallengeRepository,
|
||||
type IMfaChallengeRepository,
|
||||
} from '../../../domain/repositories/mfa-challenge.repository';
|
||||
import { USER_REPOSITORY, IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { MfaService } from '../../../infrastructure/services/mfa.service';
|
||||
import { TokenService, TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { type MfaService } from '../../../infrastructure/services/mfa.service';
|
||||
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import { UseBackupCodeCommand } from './use-backup-code.command';
|
||||
|
||||
@CommandHandler(UseBackupCodeCommand)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService, UnauthorizedException } from '@modules/shared';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared';
|
||||
import {
|
||||
MFA_CHALLENGE_REPOSITORY,
|
||||
IMfaChallengeRepository,
|
||||
type IMfaChallengeRepository,
|
||||
} from '../../../domain/repositories/mfa-challenge.repository';
|
||||
import { USER_REPOSITORY, IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { MfaService } from '../../../infrastructure/services/mfa.service';
|
||||
import { TokenService, TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { type MfaService } from '../../../infrastructure/services/mfa.service';
|
||||
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import { VerifyMfaChallengeCommand } from './verify-mfa-challenge.command';
|
||||
|
||||
@CommandHandler(VerifyMfaChallengeCommand)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventBus } from '@nestjs/cqrs';
|
||||
import { type EventBus } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type OAuthProvider, Prisma } from '@prisma/client';
|
||||
import { PrismaService, LoggerService } from '@modules/shared';
|
||||
import { type OAuthProvider, type Prisma } from '@prisma/client';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { UserEntity } from '../../domain/entities/user.entity';
|
||||
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
|
||||
import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
|
||||
import { Email } from '../../domain/value-objects/email.vo';
|
||||
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||
import { TokenService, TokenPair } from './token.service';
|
||||
import { type TokenService, type TokenPair } from './token.service';
|
||||
|
||||
export interface OAuthUserProfile {
|
||||
provider: OAuthProvider;
|
||||
|
||||
@@ -6,13 +6,10 @@ import {
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { type Request, type Response } from 'express';
|
||||
import {
|
||||
@@ -20,15 +17,12 @@ import {
|
||||
EndpointRateLimitGuard,
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
FileValidationPipe,
|
||||
type UploadedFile as ValidatedFile,
|
||||
} from '@modules/shared';
|
||||
import { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
|
||||
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
|
||||
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
|
||||
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
|
||||
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
|
||||
import { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
|
||||
import { type KycUploadUrlResult } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
||||
import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command';
|
||||
import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command';
|
||||
import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler';
|
||||
@@ -46,9 +40,9 @@ import { Roles } from '../decorators/roles.decorator';
|
||||
import { LoginDto } from '../dto/login.dto';
|
||||
import { type RefreshTokenDto } from '../dto/refresh-token.dto';
|
||||
import { type RegisterDto } from '../dto/register.dto';
|
||||
import { type UpdateProfileDto } from '../dto/update-profile.dto';
|
||||
import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
|
||||
import { type VerifyKycDto } from '../dto/verify-kyc.dto';
|
||||
import { UpdateProfileDto } from '../dto/update-profile.dto';
|
||||
import { VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { LocalAuthGuard } from '../guards/local-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
|
||||
@@ -7,21 +7,21 @@ import {
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Response } from 'express';
|
||||
import { type Response } from 'express';
|
||||
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
||||
import { DisableMfaCommand } from '../../application/commands/disable-mfa/disable-mfa.command';
|
||||
import { SetupMfaCommand } from '../../application/commands/setup-mfa/setup-mfa.command';
|
||||
import { SetupMfaResultDto } from '../../application/commands/setup-mfa/setup-mfa.handler';
|
||||
import { type SetupMfaResultDto } from '../../application/commands/setup-mfa/setup-mfa.handler';
|
||||
import { UseBackupCodeCommand } from '../../application/commands/use-backup-code/use-backup-code.command';
|
||||
import { VerifyMfaChallengeCommand } from '../../application/commands/verify-mfa-challenge/verify-mfa-challenge.command';
|
||||
import { VerifyMfaSetupCommand } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.command';
|
||||
import { VerifyMfaSetupResultDto } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.handler';
|
||||
import { MfaStatusDto } from '../../application/queries/get-mfa-status/get-mfa-status.handler';
|
||||
import { type VerifyMfaSetupResultDto } from '../../application/commands/verify-mfa-setup/verify-mfa-setup.handler';
|
||||
import { type MfaStatusDto } from '../../application/queries/get-mfa-status/get-mfa-status.handler';
|
||||
import { GetMfaStatusQuery } from '../../application/queries/get-mfa-status/get-mfa-status.query';
|
||||
import { TokenService, JwtPayload, TokenPair } from '../../infrastructure/services/token.service';
|
||||
import { type TokenService, type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
import {
|
||||
type VerifyMfaSetupDto,
|
||||
|
||||
@@ -7,18 +7,18 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command';
|
||||
import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command';
|
||||
import { UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler';
|
||||
import { type UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler';
|
||||
import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command';
|
||||
import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command';
|
||||
import { JwtPayload } from '../../infrastructure/services/token.service';
|
||||
import { type JwtPayload } from '../../infrastructure/services/token.service';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
import { Roles } from '../decorators/roles.decorator';
|
||||
import { ForceDeleteUserDto } from '../dto/force-delete-user.dto';
|
||||
import { RequestDeletionDto } from '../dto/request-deletion.dto';
|
||||
import { type ForceDeleteUserDto } from '../dto/force-delete-user.dto';
|
||||
import { type RequestDeletionDto } from '../dto/request-deletion.dto';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -16,16 +16,16 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command';
|
||||
import { CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler';
|
||||
import { type CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler';
|
||||
import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command';
|
||||
import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query';
|
||||
import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
|
||||
import { InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
|
||||
import { PaginatedResult } from '../../domain/repositories/inquiry.repository';
|
||||
import { CreateInquiryDto } from '../dto/create-inquiry.dto';
|
||||
import { ListInquiriesDto } from '../dto/list-inquiries.dto';
|
||||
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
|
||||
import { type PaginatedResult } from '../../domain/repositories/inquiry.repository';
|
||||
import { type CreateInquiryDto } from '../dto/create-inquiry.dto';
|
||||
import { type ListInquiriesDto } from '../dto/list-inquiries.dto';
|
||||
|
||||
@ApiTags('inquiries')
|
||||
@Controller('inquiries')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Lead as PrismaLead } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { LeadEntity, LeadStatus } from '../../domain/entities/lead.entity';
|
||||
import { LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import { ILeadRepository, LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import { type Lead as PrismaLead } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity';
|
||||
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import { type ILeadRepository, type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import { LeadScore } from '../../domain/value-objects/lead-score.vo';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -17,18 +17,18 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtPayload, CurrentUser, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth';
|
||||
import { CreateLeadCommand } from '../../application/commands/create-lead/create-lead.command';
|
||||
import { CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler';
|
||||
import { type CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler';
|
||||
import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command';
|
||||
import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command';
|
||||
import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query';
|
||||
import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query';
|
||||
import { LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import { LeadStatsData, PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import { CreateLeadDto } from '../dto/create-lead.dto';
|
||||
import { ListLeadsDto } from '../dto/list-leads.dto';
|
||||
import { UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
|
||||
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
||||
import { type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
|
||||
import { type CreateLeadDto } from '../dto/create-lead.dto';
|
||||
import { type ListLeadsDto } from '../dto/list-leads.dto';
|
||||
import { type UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
|
||||
|
||||
@ApiTags('leads')
|
||||
@ApiBearerAuth('JWT')
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetPriceHistoryQuery {
|
||||
constructor(public readonly listingId: string) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Listing as PrismaListing, ListingStatus } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { ListingEntity, ListingProps } from '../../domain/entities/listing.entity';
|
||||
import { ListingDetailData, ListingSearchItem, ListingSellerItem } from '../../domain/repositories/listing-read.dto';
|
||||
import { IListingRepository, ListingSearchParams, PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
import { type Listing as PrismaListing, type ListingStatus } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
|
||||
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
|
||||
import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
import { Price } from '../../domain/value-objects/price.vo';
|
||||
import { findByIdWithProperty, searchListings, findBySellerIdQuery } from './listing-read.queries';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, PropertyMedia as PrismaMedia, PropertyType, Direction } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { PropertyMediaEntity, PropertyMediaProps } from '../../domain/entities/property-media.entity';
|
||||
import { PropertyEntity, PropertyProps } from '../../domain/entities/property.entity';
|
||||
import { IPropertyRepository } from '../../domain/repositories/property.repository';
|
||||
import { type Prisma, type PropertyMedia as PrismaMedia, type PropertyType, type Direction } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity';
|
||||
import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity';
|
||||
import { type IPropertyRepository } from '../../domain/repositories/property.repository';
|
||||
import { Address } from '../../domain/value-objects/address.vo';
|
||||
import { GeoPoint } from '../../domain/value-objects/geo-point.vo';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Ip,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -29,6 +30,8 @@ import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValid
|
||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
|
||||
import type { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
|
||||
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
|
||||
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
|
||||
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
|
||||
import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
|
||||
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { CreateListingDto } from './create-listing.dto';
|
||||
export { FeatureListingDto } from './feature-listing.dto';
|
||||
export { UpdateListingDto } from './update-listing.dto';
|
||||
export { UpdateListingStatusDto } from './update-listing-status.dto';
|
||||
export { ModerateListingDto } from './moderate-listing.dto';
|
||||
|
||||
@@ -15,6 +15,8 @@ describe('SendNotificationHandler', () => {
|
||||
};
|
||||
let mockEmailService: { 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 mockEventBus: { publish: 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' }) };
|
||||
mockFcmService = { send: vi.fn().mockResolvedValue('fcm-msg-1') };
|
||||
mockStringeeSmsService = {
|
||||
sendNotification: vi.fn().mockResolvedValue({ messageId: 'sms-msg-1' }),
|
||||
isAvailable: true,
|
||||
};
|
||||
mockZaloOaService = {
|
||||
sendMessage: vi.fn().mockResolvedValue({ messageId: 'zalo-msg-1' }),
|
||||
isAvailable: false,
|
||||
};
|
||||
mockTemplateService = {
|
||||
render: vi.fn().mockReturnValue({ subject: 'Chào mừng!', body: '<p>Chào mừng!</p>' }),
|
||||
};
|
||||
@@ -57,6 +67,8 @@ describe('SendNotificationHandler', () => {
|
||||
mockPreferenceRepo as any,
|
||||
mockEmailService as any,
|
||||
mockFcmService as any,
|
||||
mockStringeeSmsService as any,
|
||||
mockZaloOaService as any,
|
||||
mockTemplateService as any,
|
||||
mockEventBus as any,
|
||||
mockLogger as any,
|
||||
@@ -97,6 +109,52 @@ describe('SendNotificationHandler', () => {
|
||||
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT');
|
||||
});
|
||||
|
||||
it('sends SMS notification via Stringee successfully', async () => {
|
||||
const command = new SendNotificationCommand(
|
||||
'user-1', 'SMS', 'user.registered', { phone: '0901234567', role: 'BUYER' }, '+84901234567',
|
||||
);
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockStringeeSmsService.sendNotification).toHaveBeenCalledWith({
|
||||
to: '+84901234567',
|
||||
message: 'Chào mừng!', // HTML stripped
|
||||
});
|
||||
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT');
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs pending when Stringee SMS is not available', async () => {
|
||||
mockStringeeSmsService.isAvailable = false;
|
||||
|
||||
const command = new SendNotificationCommand(
|
||||
'user-1', 'SMS', 'user.registered', {}, '+84901234567',
|
||||
);
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Stringee is not configured'),
|
||||
'SendNotificationHandler',
|
||||
);
|
||||
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING');
|
||||
expect(mockStringeeSmsService.sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks notification as FAILED when SMS send throws', async () => {
|
||||
mockStringeeSmsService.sendNotification.mockRejectedValue(new Error('Stringee API error'));
|
||||
|
||||
const command = new SendNotificationCommand(
|
||||
'user-1', 'SMS', 'user.registered', {}, '+84901234567',
|
||||
);
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'FAILED', 'Stringee API error');
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips notification when user preference is disabled', async () => {
|
||||
mockPreferenceRepo.isEnabled.mockResolvedValue(false);
|
||||
|
||||
@@ -111,28 +169,19 @@ describe('SendNotificationHandler', () => {
|
||||
expect(mockLogger.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs pending status for unimplemented channels (SMS)', async () => {
|
||||
const command = new SendNotificationCommand(
|
||||
'user-1', 'SMS', 'user.registered', {}, '+84901234567',
|
||||
);
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING');
|
||||
expect(mockEmailService.send).not.toHaveBeenCalled();
|
||||
expect(mockFcmService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs pending status for unimplemented channels (ZALO_OA)', async () => {
|
||||
it('logs pending when Zalo OA is not available', async () => {
|
||||
const command = new SendNotificationCommand(
|
||||
'user-1', 'ZALO_OA', 'user.registered', {}, 'zalo-id',
|
||||
);
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Zalo OA is not configured'),
|
||||
'SendNotificationHandler',
|
||||
);
|
||||
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING');
|
||||
expect(mockZaloOaService.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks notification as FAILED when email send throws', async () => {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, EventBusService, LoggerService } from '@modules/shared';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, type EventBusService, type LoggerService } from '@modules/shared';
|
||||
import { NotificationSentEvent } from '../../../domain/events/notification-sent.event';
|
||||
import {
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY,
|
||||
INotificationPreferenceRepository,
|
||||
type INotificationPreferenceRepository,
|
||||
} from '../../../domain/repositories/notification-preference.repository';
|
||||
import {
|
||||
NOTIFICATION_REPOSITORY,
|
||||
INotificationRepository,
|
||||
type INotificationRepository,
|
||||
} from '../../../domain/repositories/notification.repository';
|
||||
import { EmailService } from '../../../infrastructure/services/email.service';
|
||||
import { FcmService } from '../../../infrastructure/services/fcm.service';
|
||||
import { TemplateService } from '../../../infrastructure/services/template.service';
|
||||
import { type EmailService } from '../../../infrastructure/services/email.service';
|
||||
import { type FcmService } from '../../../infrastructure/services/fcm.service';
|
||||
import { type StringeeSmsService } from '../../../infrastructure/services/stringee-sms.service';
|
||||
import { type TemplateService } from '../../../infrastructure/services/template.service';
|
||||
import { type ZaloOaService } from '../../../infrastructure/services/zalo-oa.service';
|
||||
import { getZaloZnsTemplates } from '../../../infrastructure/services/zalo-zns-templates';
|
||||
import { SendNotificationCommand } from './send-notification.command';
|
||||
|
||||
@CommandHandler(SendNotificationCommand)
|
||||
@@ -24,6 +27,8 @@ export class SendNotificationHandler implements ICommandHandler<SendNotification
|
||||
private readonly preferenceRepo: INotificationPreferenceRepository,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly fcmService: FcmService,
|
||||
private readonly stringeeSmsService: StringeeSmsService,
|
||||
private readonly zaloOaService: ZaloOaService,
|
||||
private readonly templateService: TemplateService,
|
||||
private readonly eventBus: EventBusService,
|
||||
private readonly logger: LoggerService,
|
||||
@@ -75,14 +80,49 @@ export class SendNotificationHandler implements ICommandHandler<SendNotification
|
||||
break;
|
||||
|
||||
case 'SMS':
|
||||
case 'ZALO_OA':
|
||||
// Placeholder — these channels will be implemented when providers are integrated
|
||||
this.logger.warn(
|
||||
`Channel ${channel} not yet implemented — notification logged but not sent`,
|
||||
'SendNotificationHandler',
|
||||
);
|
||||
await this.notificationRepo.updateStatus(notification.id, 'PENDING');
|
||||
return;
|
||||
if (!this.stringeeSmsService.isAvailable) {
|
||||
this.logger.warn(
|
||||
'SMS channel requested but Stringee is not configured — notification logged as PENDING',
|
||||
'SendNotificationHandler',
|
||||
);
|
||||
await this.notificationRepo.updateStatus(notification.id, 'PENDING');
|
||||
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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,14 +13,15 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiProperty } from '@nestjs/swagger';
|
||||
import { NotificationChannel as PrismaChannel } from '@prisma/client';
|
||||
import { IsBoolean, IsEnum, IsString } from 'class-validator';
|
||||
import { CurrentUser, JwtPayload } from '@modules/auth';
|
||||
import { CurrentUser, type JwtPayload } from '@modules/auth';
|
||||
import {
|
||||
NOTIFICATION_REPOSITORY,
|
||||
INotificationRepository,
|
||||
type INotificationRepository,
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY,
|
||||
INotificationPreferenceRepository,
|
||||
type INotificationPreferenceRepository,
|
||||
} from '../../domain';
|
||||
import { TemplateService } from '../../infrastructure/services/template.service';
|
||||
import { type TemplateService } from '../../infrastructure/services/template.service';
|
||||
import { type NotificationsGateway } from '../gateways/notifications.gateway';
|
||||
|
||||
class UpdatePreferenceDto {
|
||||
@ApiProperty({ enum: PrismaChannel, description: 'Notification channel' })
|
||||
@@ -47,6 +48,7 @@ export class NotificationsController {
|
||||
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY)
|
||||
private readonly preferenceRepo: INotificationPreferenceRepository,
|
||||
private readonly templateService: TemplateService,
|
||||
private readonly notificationsGateway: NotificationsGateway,
|
||||
) {}
|
||||
|
||||
@Get('history')
|
||||
@@ -80,6 +82,15 @@ export class NotificationsController {
|
||||
return this.preferenceRepo.upsert(user.sub, dto.channel, dto.eventType, dto.enabled);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
@ApiOperation({ summary: 'Get unread notification count (Redis-cached)' })
|
||||
@ApiResponse({ status: 200, description: 'Unread count retrieved' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getUnreadCount(@CurrentUser() user: JwtPayload) {
|
||||
const count = await this.notificationRepo.countUnreadByUserId(user.sub);
|
||||
return { unreadCount: count };
|
||||
}
|
||||
|
||||
@Get('unread')
|
||||
@ApiOperation({ summary: 'Get unread notifications' })
|
||||
@ApiResponse({ status: 200, description: 'Unread notifications retrieved' })
|
||||
@@ -105,6 +116,9 @@ export class NotificationsController {
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.notificationRepo.markAsRead(id, user.sub);
|
||||
// Invalidate cached count and push updated count via WebSocket
|
||||
await this.notificationsGateway.invalidateUnreadCount(user.sub);
|
||||
await this.notificationsGateway.emitUnreadCount(user.sub);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -114,6 +128,9 @@ export class NotificationsController {
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async markAllAsRead(@CurrentUser() user: JwtPayload) {
|
||||
const count = await this.notificationRepo.markAllAsRead(user.sub);
|
||||
// Invalidate cached count and push updated count via WebSocket
|
||||
await this.notificationsGateway.invalidateUnreadCount(user.sub);
|
||||
await this.notificationsGateway.emitUnreadCount(user.sub);
|
||||
return { markedCount: count };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { PaymentStatus } from '@prisma/client';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type PaymentStatus } from '@prisma/client';
|
||||
import {
|
||||
DomainException,
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
LoggerService,
|
||||
type LoggerService,
|
||||
} from '@modules/shared';
|
||||
import {
|
||||
PAYMENT_REPOSITORY,
|
||||
IPaymentRepository,
|
||||
type IPaymentRepository,
|
||||
} from '../../../domain/repositories/payment.repository';
|
||||
import { BankTransferService } from '../../../infrastructure/services/bank-transfer.service';
|
||||
import { type BankTransferService } from '../../../infrastructure/services/bank-transfer.service';
|
||||
import { ConfirmBankTransferCommand } from './confirm-bank-transfer.command';
|
||||
|
||||
export interface ConfirmBankTransferResult {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { PaymentStatus } from '@prisma/client';
|
||||
import { DomainException, NotFoundException, ValidationException, LoggerService } from '@modules/shared';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type PaymentStatus } from '@prisma/client';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
PAYMENT_REPOSITORY,
|
||||
IPaymentRepository,
|
||||
type IPaymentRepository,
|
||||
} from '../../../domain/repositories/payment.repository';
|
||||
import {
|
||||
PAYMENT_GATEWAY_FACTORY,
|
||||
IPaymentGatewayFactory,
|
||||
type IPaymentGatewayFactory,
|
||||
} from '../../../infrastructure/services/payment-gateway.interface';
|
||||
import { HandleCallbackCommand } from './handle-callback.command';
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { PaymentProvider } from '@prisma/client';
|
||||
import { BankTransferService } from './bank-transfer.service';
|
||||
import { MomoService } from './momo.service';
|
||||
import { type PaymentProvider } from '@prisma/client';
|
||||
import { type BankTransferService } from './bank-transfer.service';
|
||||
import { type MomoService } from './momo.service';
|
||||
import {
|
||||
type IPaymentGateway,
|
||||
IPaymentGatewayFactory,
|
||||
type IPaymentGatewayFactory,
|
||||
} from './payment-gateway.interface';
|
||||
import { VnpayService } from './vnpay.service';
|
||||
import { ZalopayService } from './zalopay.service';
|
||||
import { type VnpayService } from './vnpay.service';
|
||||
import { type ZalopayService } from './zalopay.service';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentGatewayFactory implements IPaymentGatewayFactory {
|
||||
|
||||
@@ -6,26 +6,26 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { CancelOrderCommand } from '../../application/commands/cancel-order/cancel-order.command';
|
||||
import { CancelOrderResult } from '../../application/commands/cancel-order/cancel-order.handler';
|
||||
import { type CancelOrderResult } from '../../application/commands/cancel-order/cancel-order.handler';
|
||||
import { CreateOrderCommand } from '../../application/commands/create-order/create-order.command';
|
||||
import { CreateOrderResult } from '../../application/commands/create-order/create-order.handler';
|
||||
import { type CreateOrderResult } from '../../application/commands/create-order/create-order.handler';
|
||||
import { HoldEscrowCommand } from '../../application/commands/hold-escrow/hold-escrow.command';
|
||||
import { HoldEscrowResult } from '../../application/commands/hold-escrow/hold-escrow.handler';
|
||||
import { type HoldEscrowResult } from '../../application/commands/hold-escrow/hold-escrow.handler';
|
||||
import { ReleaseEscrowCommand } from '../../application/commands/release-escrow/release-escrow.command';
|
||||
import { ReleaseEscrowResult } from '../../application/commands/release-escrow/release-escrow.handler';
|
||||
import { OrderStatusDto } from '../../application/queries/get-order-status/get-order-status.handler';
|
||||
import { type ReleaseEscrowResult } from '../../application/commands/release-escrow/release-escrow.handler';
|
||||
import { type OrderStatusDto } from '../../application/queries/get-order-status/get-order-status.handler';
|
||||
import { GetOrderStatusQuery } from '../../application/queries/get-order-status/get-order-status.query';
|
||||
import { CancelOrderDto } from '../dto/cancel-order.dto';
|
||||
import { CreateOrderDto } from '../dto/create-order.dto';
|
||||
import { type CancelOrderDto } from '../dto/cancel-order.dto';
|
||||
import { type CreateOrderDto } from '../dto/create-order.dto';
|
||||
|
||||
@ApiTags('orders')
|
||||
@Controller('orders')
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -17,25 +17,25 @@ import {
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { PaymentProvider } from '@prisma/client';
|
||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { type PaymentProvider } from '@prisma/client';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
||||
import { ConfirmBankTransferCommand } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
|
||||
import { ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
|
||||
import { type ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
|
||||
import { CreatePaymentCommand } from '../../application/commands/create-payment/create-payment.command';
|
||||
import { CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler';
|
||||
import { type CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler';
|
||||
import { HandleCallbackCommand } from '../../application/commands/handle-callback/handle-callback.command';
|
||||
import { HandleCallbackResult } from '../../application/commands/handle-callback/handle-callback.handler';
|
||||
import { type HandleCallbackResult } from '../../application/commands/handle-callback/handle-callback.handler';
|
||||
import { RefundPaymentCommand } from '../../application/commands/refund-payment/refund-payment.command';
|
||||
import { RefundPaymentResult } from '../../application/commands/refund-payment/refund-payment.handler';
|
||||
import { PaymentStatusDto } from '../../application/queries/get-payment-status/get-payment-status.handler';
|
||||
import { type RefundPaymentResult } from '../../application/commands/refund-payment/refund-payment.handler';
|
||||
import { type PaymentStatusDto } from '../../application/queries/get-payment-status/get-payment-status.handler';
|
||||
import { GetPaymentStatusQuery } from '../../application/queries/get-payment-status/get-payment-status.query';
|
||||
import { TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler';
|
||||
import { type TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler';
|
||||
import { ListTransactionsQuery } from '../../application/queries/list-transactions/list-transactions.query';
|
||||
import { ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto';
|
||||
import { CreatePaymentDto } from '../dto/create-payment.dto';
|
||||
import { ListTransactionsDto } from '../dto/list-transactions.dto';
|
||||
import { RefundPaymentDto } from '../dto/refund-payment.dto';
|
||||
import { type ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto';
|
||||
import { type CreatePaymentDto } from '../dto/create-payment.dto';
|
||||
import { type ListTransactionsDto } from '../dto/list-transactions.dto';
|
||||
import { type RefundPaymentDto } from '../dto/refund-payment.dto';
|
||||
|
||||
@ApiTags('payments')
|
||||
@Controller('payments')
|
||||
|
||||
@@ -17,17 +17,17 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
|
||||
import { CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
|
||||
import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
|
||||
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
|
||||
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
|
||||
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
|
||||
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
|
||||
import { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import { PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
import { CreateReviewDto } from '../dto/create-review.dto';
|
||||
import { ListReviewsByTargetDto, ReviewStatsDto } from '../dto/list-reviews.dto';
|
||||
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import { type PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
import { type CreateReviewDto } from '../dto/create-review.dto';
|
||||
import { type ListReviewsByTargetDto, type ReviewStatsDto } from '../dto/list-reviews.dto';
|
||||
|
||||
@ApiTags('reviews')
|
||||
@Controller('reviews')
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||
import { Counter } from 'prom-client';
|
||||
import { type Counter } from 'prom-client';
|
||||
import {
|
||||
CircuitBreaker,
|
||||
CircuitOpenError,
|
||||
type CircuitState,
|
||||
LoggerService,
|
||||
type LoggerService,
|
||||
} from '@modules/shared';
|
||||
import {
|
||||
ISearchRepository,
|
||||
type ISearchRepository,
|
||||
type ListingDocument,
|
||||
type SearchParams,
|
||||
type SearchResult,
|
||||
} from '../../domain/repositories/search.repository';
|
||||
import { PostgresSearchRepository } from './postgres-search.repository';
|
||||
import { TypesenseSearchRepository } from './typesense-search.repository';
|
||||
import { type PostgresSearchRepository } from './postgres-search.repository';
|
||||
import { type TypesenseSearchRepository } from './typesense-search.repository';
|
||||
|
||||
export const SEARCH_DEGRADATION_TOTAL = 'search_degradation_total';
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Client as TypesenseClient } from 'typesense';
|
||||
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { type Client as TypesenseClient } from 'typesense';
|
||||
import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
ISearchRepository,
|
||||
type ISearchRepository,
|
||||
type ListingDocument,
|
||||
type SearchParams,
|
||||
type SearchResult,
|
||||
} from '../../domain/repositories/search.repository';
|
||||
import { TypesenseClientService } from './typesense-client.service';
|
||||
import { type TypesenseClientService } from './typesense-client.service';
|
||||
|
||||
const COLLECTION_NAME = 'listings';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -17,17 +17,17 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { CreateSavedSearchCommand } from '../../application/commands/create-saved-search/create-saved-search.command';
|
||||
import { CreateSavedSearchResult } from '../../application/commands/create-saved-search/create-saved-search.handler';
|
||||
import { type CreateSavedSearchResult } from '../../application/commands/create-saved-search/create-saved-search.handler';
|
||||
import { DeleteSavedSearchCommand } from '../../application/commands/delete-saved-search/delete-saved-search.command';
|
||||
import { UpdateSavedSearchCommand } from '../../application/commands/update-saved-search/update-saved-search.command';
|
||||
import { UpdateSavedSearchResult } from '../../application/commands/update-saved-search/update-saved-search.handler';
|
||||
import { SavedSearchDetail } from '../../application/queries/get-saved-search/get-saved-search.handler';
|
||||
import { type UpdateSavedSearchResult } from '../../application/commands/update-saved-search/update-saved-search.handler';
|
||||
import { type SavedSearchDetail } from '../../application/queries/get-saved-search/get-saved-search.handler';
|
||||
import { GetSavedSearchQuery } from '../../application/queries/get-saved-search/get-saved-search.query';
|
||||
import { SavedSearchListResult } from '../../application/queries/get-saved-searches/get-saved-searches.handler';
|
||||
import { type SavedSearchListResult } from '../../application/queries/get-saved-searches/get-saved-searches.handler';
|
||||
import { GetSavedSearchesQuery } from '../../application/queries/get-saved-searches/get-saved-searches.query';
|
||||
import { CreateSavedSearchDto, UpdateSavedSearchDto, SavedSearchListDto } from '../dto/saved-search.dto';
|
||||
import { type CreateSavedSearchDto, type UpdateSavedSearchDto, type SavedSearchListDto } from '../dto/saved-search.dto';
|
||||
|
||||
@ApiTags('saved-searches')
|
||||
@ApiBearerAuth('JWT')
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
import { Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
||||
import { ReindexAllCommand } from '../../application/commands/reindex-all/reindex-all.command';
|
||||
import { ReindexResult } from '../../application/commands/reindex-all/reindex-all.handler';
|
||||
import { type ReindexResult } from '../../application/commands/reindex-all/reindex-all.handler';
|
||||
import { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query';
|
||||
import { SearchPropertiesQuery } from '../../application/queries/search-properties/search-properties.query';
|
||||
import { SearchResult } from '../../domain/repositories/search.repository';
|
||||
import { GeoSearchDto } from '../dto/geo-search.dto';
|
||||
import { SearchPropertiesDto } from '../dto/search-properties.dto';
|
||||
import { type SearchResult } from '../../domain/repositories/search.repository';
|
||||
import { type GeoSearchDto } from '../dto/geo-search.dto';
|
||||
import { type SearchPropertiesDto } from '../dto/search-properties.dto';
|
||||
|
||||
@ApiTags('search')
|
||||
@Controller('search')
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -17,27 +17,27 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { PlanTier } from '@prisma/client';
|
||||
import { JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { type PlanTier } from '@prisma/client';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { CancelSubscriptionCommand } from '../../application/commands/cancel-subscription/cancel-subscription.command';
|
||||
import { CancelSubscriptionResult } from '../../application/commands/cancel-subscription/cancel-subscription.handler';
|
||||
import { type CancelSubscriptionResult } from '../../application/commands/cancel-subscription/cancel-subscription.handler';
|
||||
import { CreateSubscriptionCommand } from '../../application/commands/create-subscription/create-subscription.command';
|
||||
import { CreateSubscriptionResult } from '../../application/commands/create-subscription/create-subscription.handler';
|
||||
import { type CreateSubscriptionResult } from '../../application/commands/create-subscription/create-subscription.handler';
|
||||
import { MeterUsageCommand } from '../../application/commands/meter-usage/meter-usage.command';
|
||||
import { MeterUsageResult } from '../../application/commands/meter-usage/meter-usage.handler';
|
||||
import { type MeterUsageResult } from '../../application/commands/meter-usage/meter-usage.handler';
|
||||
import { UpgradeSubscriptionCommand } from '../../application/commands/upgrade-subscription/upgrade-subscription.command';
|
||||
import { UpgradeSubscriptionResult } from '../../application/commands/upgrade-subscription/upgrade-subscription.handler';
|
||||
import { QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler';
|
||||
import { type UpgradeSubscriptionResult } from '../../application/commands/upgrade-subscription/upgrade-subscription.handler';
|
||||
import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler';
|
||||
import { CheckQuotaQuery } from '../../application/queries/check-quota/check-quota.query';
|
||||
import { BillingHistoryDto } from '../../application/queries/get-billing-history/get-billing-history.handler';
|
||||
import { type BillingHistoryDto } from '../../application/queries/get-billing-history/get-billing-history.handler';
|
||||
import { GetBillingHistoryQuery } from '../../application/queries/get-billing-history/get-billing-history.query';
|
||||
import { PlanDto } from '../../application/queries/get-plan/get-plan.handler';
|
||||
import { type PlanDto } from '../../application/queries/get-plan/get-plan.handler';
|
||||
import { GetPlanQuery } from '../../application/queries/get-plan/get-plan.query';
|
||||
import { BillingHistoryParamsDto } from '../dto/billing-history.dto';
|
||||
import { CancelSubscriptionDto } from '../dto/cancel-subscription.dto';
|
||||
import { CreateSubscriptionDto } from '../dto/create-subscription.dto';
|
||||
import { MeterUsageDto } from '../dto/meter-usage.dto';
|
||||
import { UpgradeSubscriptionDto } from '../dto/upgrade-subscription.dto';
|
||||
import { type BillingHistoryParamsDto } from '../dto/billing-history.dto';
|
||||
import { type CancelSubscriptionDto } from '../dto/cancel-subscription.dto';
|
||||
import { type CreateSubscriptionDto } from '../dto/create-subscription.dto';
|
||||
import { type MeterUsageDto } from '../dto/meter-usage.dto';
|
||||
import { type UpgradeSubscriptionDto } from '../dto/upgrade-subscription.dto';
|
||||
|
||||
@ApiTags('subscriptions')
|
||||
@Controller('subscriptions')
|
||||
|
||||
Reference in New Issue
Block a user