fix(api): add error handling to remaining 51 CQRS handlers across 8 modules
Wraps every handler's execute() method in a try-catch block that: - Re-throws DomainExceptions to preserve structured error responses - Logs unexpected infrastructure errors with full context - Throws InternalServerErrorException with Vietnamese user message Modules updated: - auth (11 handlers: register, refresh-token, verify-kyc, deletions, profile queries) - listings (7 handlers: create, moderate, upload, status, search, queries) - payments (5 handlers: create, callback, refund, status, transactions) - subscriptions (7 handlers: create, cancel, upgrade, meter, quota, billing, plans) - analytics (8 handlers: reports, events, market-index, district, heatmap, trends, valuation) - search (9 handlers: saved-search CRUD, reindex, sync, geo-search, properties) - notifications (1 handler: send-notification) - agents (3 handlers: quality-score, dashboard, public-profile) Combined with the previous commit (29 handlers in admin, inquiries, leads, reviews), all 80+ CQRS handlers now have comprehensive error handling. Verification: - pnpm typecheck: 0 errors - pnpm test: 1387 tests passed (228 files) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type LoggerService, type PrismaService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { type LoggerService, type PrismaService, DomainException, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { CancelUserDeletionCommand } from './cancel-user-deletion.command';
|
||||
|
||||
@CommandHandler(CancelUserDeletionCommand)
|
||||
@@ -10,17 +11,27 @@ export class CancelUserDeletionHandler implements ICommandHandler<CancelUserDele
|
||||
) {}
|
||||
|
||||
async execute(command: CancelUserDeletionCommand): Promise<{ message: string }> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: command.userId } });
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa vĩnh viễn');
|
||||
if (!user.deletionScheduledAt) throw new ValidationException('Không có yêu cầu xóa nào đang chờ');
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: command.userId } });
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa vĩnh viễn');
|
||||
if (!user.deletionScheduledAt) throw new ValidationException('Không có yêu cầu xóa nào đang chờ');
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: command.userId },
|
||||
data: { deletionScheduledAt: null, isActive: true },
|
||||
});
|
||||
await this.prisma.user.update({
|
||||
where: { id: command.userId },
|
||||
data: { deletionScheduledAt: null, isActive: true },
|
||||
});
|
||||
|
||||
this.logger.log(`User ${command.userId} deletion cancelled`, 'CancelUserDeletionHandler');
|
||||
return { message: 'Đã hủy yêu cầu xóa tài khoản' };
|
||||
this.logger.log(`User ${command.userId} deletion cancelled`, 'CancelUserDeletionHandler');
|
||||
return { message: 'Đã hủy yêu cầu xóa tài khoản' };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to cancel user deletion: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể hủy yêu cầu xóa tài khoản');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type LoggerService, type PrismaService, NotFoundException } from '@modules/shared';
|
||||
import { type LoggerService, type PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
||||
import { ExportUserDataCommand } from './export-user-data.command';
|
||||
|
||||
export interface UserDataExport {
|
||||
@@ -31,27 +32,18 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
||||
) {}
|
||||
|
||||
async execute(command: ExportUserDataCommand): Promise<UserDataExport> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: command.userId },
|
||||
select: {
|
||||
id: true, email: true, phone: true, fullName: true,
|
||||
role: true, kycStatus: true, createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
|
||||
let agent: unknown | null;
|
||||
let listings: unknown[];
|
||||
let payments: unknown[];
|
||||
let subscription: unknown | null;
|
||||
let reviews: unknown[];
|
||||
let inquiries: unknown[];
|
||||
let savedSearches: unknown[];
|
||||
let transactions: unknown[];
|
||||
|
||||
try {
|
||||
[agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: command.userId },
|
||||
select: {
|
||||
id: true, email: true, phone: true, fullName: true,
|
||||
role: true, kycStatus: true, createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
|
||||
const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
||||
await Promise.all([
|
||||
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
|
||||
this.prisma.listing.findMany({
|
||||
@@ -68,28 +60,29 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
||||
this.prisma.savedSearch.findMany({ where: { userId: command.userId } }),
|
||||
this.prisma.transaction.findMany({ where: { buyerId: command.userId } }),
|
||||
]);
|
||||
|
||||
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
|
||||
|
||||
return {
|
||||
user,
|
||||
agent,
|
||||
listings,
|
||||
payments,
|
||||
subscription,
|
||||
reviews,
|
||||
inquiries,
|
||||
savedSearches,
|
||||
transactions,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to export user data for ${command.userId}: ${error instanceof Error ? error.message : error}`,
|
||||
`Failed to export user data: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'ExportUserDataHandler',
|
||||
this.constructor.name,
|
||||
);
|
||||
throw error;
|
||||
throw new InternalServerErrorException('Không thể xuất dữ liệu người dùng');
|
||||
}
|
||||
|
||||
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
|
||||
|
||||
return {
|
||||
user,
|
||||
agent,
|
||||
listings,
|
||||
payments,
|
||||
subscription,
|
||||
reviews,
|
||||
inquiries,
|
||||
savedSearches,
|
||||
transactions,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { type LoggerService, type PrismaService, NotFoundException } from '@modules/shared';
|
||||
import { type LoggerService, type PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
||||
import { ForceDeleteUserCommand } from './force-delete-user.command';
|
||||
|
||||
@CommandHandler(ForceDeleteUserCommand)
|
||||
@@ -11,27 +12,28 @@ export class ForceDeleteUserHandler implements ICommandHandler<ForceDeleteUserCo
|
||||
) {}
|
||||
|
||||
async execute(command: ForceDeleteUserCommand): Promise<{ message: string }> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: command.userId } });
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
if (user.deletedAt) return { message: 'Tài khoản đã bị xóa' };
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: command.userId } });
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
if (user.deletedAt) return { message: 'Tài khoản đã bị xóa' };
|
||||
|
||||
await this.anonymizeAndDelete(command.userId);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Force delete transaction failed for user ${command.userId}: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
|
||||
this.logger.log(
|
||||
`User ${command.userId} force-deleted by admin ${command.adminId}: ${command.reason}`,
|
||||
'ForceDeleteUserHandler',
|
||||
);
|
||||
throw error;
|
||||
|
||||
return { message: 'Tài khoản đã bị xóa vĩnh viễn' };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to force delete user: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể xóa vĩnh viễn tài khoản người dùng');
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`User ${command.userId} force-deleted by admin ${command.adminId}: ${command.reason}`,
|
||||
'ForceDeleteUserHandler',
|
||||
);
|
||||
|
||||
return { message: 'Tài khoản đã bị xóa vĩnh viễn' };
|
||||
}
|
||||
|
||||
private async anonymizeAndDelete(userId: string): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type LoggerService, UnauthorizedException } from '@modules/shared';
|
||||
import { type LoggerService, DomainException, UnauthorizedException } from '@modules/shared';
|
||||
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import { LoginUserCommand } from './login-user.command';
|
||||
|
||||
@@ -18,12 +19,13 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
||||
role: command.role,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Token generation failed for user ${command.userId}: ${error instanceof Error ? error.message : error}`,
|
||||
`Failed to login user: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'LoginUserHandler',
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new UnauthorizedException('Không thể tạo phiên đăng nhập, vui lòng thử lại');
|
||||
throw new InternalServerErrorException('Không thể tạo phiên đăng nhập, vui lòng thử lại');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type CommandBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
import { type LoggerService, type PrismaService, DomainException } from '@modules/shared';
|
||||
import { ForceDeleteUserCommand } from '../force-delete-user/force-delete-user.command';
|
||||
import { ProcessScheduledDeletionsCommand } from './process-scheduled-deletions.command';
|
||||
|
||||
@@ -12,37 +13,47 @@ export class ProcessScheduledDeletionsHandler implements ICommandHandler<Process
|
||||
) {}
|
||||
|
||||
async execute(): Promise<{ processedCount: number }> {
|
||||
const now = new Date();
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
const usersToDelete = await this.prisma.user.findMany({
|
||||
where: {
|
||||
deletionScheduledAt: { lte: now },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const usersToDelete = await this.prisma.user.findMany({
|
||||
where: {
|
||||
deletionScheduledAt: { lte: now },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Processing ${usersToDelete.length} scheduled deletions`,
|
||||
'ProcessScheduledDeletionsHandler',
|
||||
);
|
||||
this.logger.log(
|
||||
`Processing ${usersToDelete.length} scheduled deletions`,
|
||||
'ProcessScheduledDeletionsHandler',
|
||||
);
|
||||
|
||||
let processedCount = 0;
|
||||
for (const user of usersToDelete) {
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new ForceDeleteUserCommand(user.id, 'SYSTEM', 'Scheduled GDPR deletion after 30-day grace period'),
|
||||
);
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process deletion for user ${user.id}: ${error}`,
|
||||
undefined,
|
||||
'ProcessScheduledDeletionsHandler',
|
||||
);
|
||||
let processedCount = 0;
|
||||
for (const user of usersToDelete) {
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new ForceDeleteUserCommand(user.id, 'SYSTEM', 'Scheduled GDPR deletion after 30-day grace period'),
|
||||
);
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process deletion for user ${user.id}: ${error}`,
|
||||
undefined,
|
||||
'ProcessScheduledDeletionsHandler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { processedCount };
|
||||
return { processedCount };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to process scheduled deletions: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể xử lý các yêu cầu xóa tài khoản theo lịch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type LoggerService, UnauthorizedException } from '@modules/shared';
|
||||
import { type LoggerService, DomainException, UnauthorizedException } from '@modules/shared';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import { RefreshTokenCommand } from './refresh-token.command';
|
||||
@@ -14,37 +14,47 @@ export class RefreshTokenHandler implements ICommandHandler<RefreshTokenCommand>
|
||||
) {}
|
||||
|
||||
async execute(command: RefreshTokenCommand): Promise<TokenPair> {
|
||||
let rotated: Awaited<ReturnType<TokenService['rotateRefreshToken']>>;
|
||||
try {
|
||||
rotated = await this.tokenService.rotateRefreshToken(command.refreshToken);
|
||||
let rotated: Awaited<ReturnType<TokenService['rotateRefreshToken']>>;
|
||||
try {
|
||||
rotated = await this.tokenService.rotateRefreshToken(command.refreshToken);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Token rotation failed: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'RefreshTokenHandler',
|
||||
);
|
||||
throw new UnauthorizedException('Không thể làm mới phiên đăng nhập');
|
||||
}
|
||||
|
||||
if (!rotated) {
|
||||
throw new UnauthorizedException('Refresh token không hợp lệ hoặc đã hết hạn');
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findById(rotated.userId);
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Tài khoản không tồn tại hoặc đã bị vô hiệu hóa');
|
||||
}
|
||||
|
||||
const accessToken = this.tokenService.generateAccessToken({
|
||||
sub: user.id,
|
||||
phone: user.phone.value,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: rotated.refreshToken,
|
||||
expiresIn: 900,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Token rotation failed: ${error instanceof Error ? error.message : error}`,
|
||||
`Failed to refresh token: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'RefreshTokenHandler',
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new UnauthorizedException('Không thể làm mới phiên đăng nhập');
|
||||
throw new InternalServerErrorException('Không thể làm mới phiên đăng nhập');
|
||||
}
|
||||
|
||||
if (!rotated) {
|
||||
throw new UnauthorizedException('Refresh token không hợp lệ hoặc đã hết hạn');
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findById(rotated.userId);
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Tài khoản không tồn tại hoặc đã bị vô hiệu hóa');
|
||||
}
|
||||
|
||||
const accessToken = this.tokenService.generateAccessToken({
|
||||
sub: user.id,
|
||||
phone: user.phone.value,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: rotated.refreshToken,
|
||||
expiresIn: 900,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { ConflictException, ValidationException } from '@modules/shared';
|
||||
import { ConflictException, DomainException, LoggerService, ValidationException } from '@modules/shared';
|
||||
import { UserEntity } from '../../../domain/entities/user.entity';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
@@ -16,62 +16,73 @@ export class RegisterUserHandler implements ICommandHandler<RegisterUserCommand>
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: RegisterUserCommand): Promise<TokenPair> {
|
||||
// Validate phone
|
||||
const phoneResult = Phone.create(command.phone);
|
||||
if (phoneResult.isErr) {
|
||||
throw new ValidationException(phoneResult.unwrapErr());
|
||||
}
|
||||
const phone = phoneResult.unwrap();
|
||||
|
||||
// Check duplicate phone
|
||||
const existingByPhone = await this.userRepo.findByPhone(phone.value);
|
||||
if (existingByPhone) {
|
||||
throw new ConflictException('Số điện thoại đã được đăng ký');
|
||||
}
|
||||
|
||||
// Validate email if provided
|
||||
let email: Email | undefined;
|
||||
if (command.email) {
|
||||
const emailResult = Email.create(command.email);
|
||||
if (emailResult.isErr) {
|
||||
throw new ValidationException(emailResult.unwrapErr());
|
||||
try {
|
||||
// Validate phone
|
||||
const phoneResult = Phone.create(command.phone);
|
||||
if (phoneResult.isErr) {
|
||||
throw new ValidationException(phoneResult.unwrapErr());
|
||||
}
|
||||
email = emailResult.unwrap();
|
||||
const phone = phoneResult.unwrap();
|
||||
|
||||
const existingByEmail = await this.userRepo.findByEmail(email.value);
|
||||
if (existingByEmail) {
|
||||
throw new ConflictException('Email đã được đăng ký');
|
||||
// Check duplicate phone
|
||||
const existingByPhone = await this.userRepo.findByPhone(phone.value);
|
||||
if (existingByPhone) {
|
||||
throw new ConflictException('Số điện thoại đã được đăng ký');
|
||||
}
|
||||
|
||||
// Validate email if provided
|
||||
let email: Email | undefined;
|
||||
if (command.email) {
|
||||
const emailResult = Email.create(command.email);
|
||||
if (emailResult.isErr) {
|
||||
throw new ValidationException(emailResult.unwrapErr());
|
||||
}
|
||||
email = emailResult.unwrap();
|
||||
|
||||
const existingByEmail = await this.userRepo.findByEmail(email.value);
|
||||
if (existingByEmail) {
|
||||
throw new ConflictException('Email đã được đăng ký');
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordResult = await HashedPassword.fromPlain(command.password);
|
||||
if (passwordResult.isErr) {
|
||||
throw new ValidationException(passwordResult.unwrapErr());
|
||||
}
|
||||
const passwordHash = passwordResult.unwrap();
|
||||
|
||||
// Create user entity
|
||||
const userId = createId();
|
||||
const user = UserEntity.createNew(userId, phone, command.fullName, passwordHash, email);
|
||||
|
||||
// Persist
|
||||
await this.userRepo.save(user);
|
||||
|
||||
// Publish domain events
|
||||
const events = user.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
return this.tokenService.generateTokenPair({
|
||||
sub: user.id,
|
||||
phone: user.phone.value,
|
||||
role: user.role,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to register user: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể đăng ký tài khoản người dùng');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordResult = await HashedPassword.fromPlain(command.password);
|
||||
if (passwordResult.isErr) {
|
||||
throw new ValidationException(passwordResult.unwrapErr());
|
||||
}
|
||||
const passwordHash = passwordResult.unwrap();
|
||||
|
||||
// Create user entity
|
||||
const userId = createId();
|
||||
const user = UserEntity.createNew(userId, phone, command.fullName, passwordHash, email);
|
||||
|
||||
// Persist
|
||||
await this.userRepo.save(user);
|
||||
|
||||
// Publish domain events
|
||||
const events = user.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
return this.tokenService.generateTokenPair({
|
||||
sub: user.id,
|
||||
phone: user.phone.value,
|
||||
role: user.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type LoggerService, type PrismaService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { type LoggerService, type PrismaService, DomainException, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { RequestUserDeletionCommand } from './request-user-deletion.command';
|
||||
|
||||
const DELETION_GRACE_PERIOD_DAYS = 30;
|
||||
@@ -12,30 +13,40 @@ export class RequestUserDeletionHandler implements ICommandHandler<RequestUserDe
|
||||
) {}
|
||||
|
||||
async execute(command: RequestUserDeletionCommand): Promise<{ scheduledAt: Date }> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: command.userId } });
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa');
|
||||
if (user.deletionScheduledAt) throw new ValidationException('Yêu cầu xóa đã tồn tại');
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: command.userId } });
|
||||
if (!user) throw new NotFoundException('User', command.userId);
|
||||
if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa');
|
||||
if (user.deletionScheduledAt) throw new ValidationException('Yêu cầu xóa đã tồn tại');
|
||||
|
||||
const scheduledAt = new Date();
|
||||
scheduledAt.setDate(scheduledAt.getDate() + DELETION_GRACE_PERIOD_DAYS);
|
||||
const scheduledAt = new Date();
|
||||
scheduledAt.setDate(scheduledAt.getDate() + DELETION_GRACE_PERIOD_DAYS);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: command.userId },
|
||||
data: { deletionScheduledAt: scheduledAt, isActive: false },
|
||||
});
|
||||
await this.prisma.user.update({
|
||||
where: { id: command.userId },
|
||||
data: { deletionScheduledAt: scheduledAt, isActive: false },
|
||||
});
|
||||
|
||||
// Revoke all refresh tokens
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId: command.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
// Revoke all refresh tokens
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId: command.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`User ${command.userId} deletion scheduled for ${scheduledAt.toISOString()}`,
|
||||
'RequestUserDeletionHandler',
|
||||
);
|
||||
this.logger.log(
|
||||
`User ${command.userId} deletion scheduled for ${scheduledAt.toISOString()}`,
|
||||
'RequestUserDeletionHandler',
|
||||
);
|
||||
|
||||
return { scheduledAt };
|
||||
return { scheduledAt };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to request user deletion: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể xử lý yêu cầu xóa tài khoản');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, CacheService, CachePrefix } from '@modules/shared';
|
||||
import { DomainException, LoggerService, NotFoundException, CacheService, CachePrefix } from '@modules/shared';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { VerifyKycCommand } from './verify-kyc.command';
|
||||
|
||||
@@ -9,17 +9,28 @@ export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: VerifyKycCommand): Promise<void> {
|
||||
const user = await this.userRepo.findById(command.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng', command.userId);
|
||||
try {
|
||||
const user = await this.userRepo.findById(command.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng', command.userId);
|
||||
}
|
||||
|
||||
user.updateKycStatus(command.kycStatus, command.kycData);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
await this.cache.invalidate(CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId));
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to verify KYC: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể xác minh KYC cho người dùng');
|
||||
}
|
||||
|
||||
user.updateKycStatus(command.kycStatus, command.kycData);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
await this.cache.invalidate(CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { type PrismaService, DomainException, LoggerService } from '@modules/shared';
|
||||
import { GetAgentByUserIdQuery } from './get-agent-by-user-id.query';
|
||||
|
||||
export interface AgentDto {
|
||||
@@ -20,27 +20,40 @@ export interface AgentDto {
|
||||
@Injectable()
|
||||
@QueryHandler(GetAgentByUserIdQuery)
|
||||
export class GetAgentByUserIdHandler implements IQueryHandler<GetAgentByUserIdQuery> {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAgentByUserIdQuery): Promise<AgentDto | null> {
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { userId: query.userId },
|
||||
});
|
||||
try {
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { userId: query.userId },
|
||||
});
|
||||
|
||||
if (!agent) return null;
|
||||
if (!agent) return null;
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
userId: agent.userId,
|
||||
licenseNumber: agent.licenseNumber,
|
||||
agency: agent.agency,
|
||||
qualityScore: agent.qualityScore,
|
||||
totalDeals: agent.totalDeals,
|
||||
responseTimeAvg: agent.responseTimeAvg,
|
||||
bio: agent.bio,
|
||||
serviceAreas: agent.serviceAreas,
|
||||
isVerified: agent.isVerified,
|
||||
createdAt: agent.createdAt,
|
||||
};
|
||||
return {
|
||||
id: agent.id,
|
||||
userId: agent.userId,
|
||||
licenseNumber: agent.licenseNumber,
|
||||
agency: agent.agency,
|
||||
qualityScore: agent.qualityScore,
|
||||
totalDeals: agent.totalDeals,
|
||||
responseTimeAvg: agent.responseTimeAvg,
|
||||
bio: agent.bio,
|
||||
serviceAreas: agent.serviceAreas,
|
||||
isVerified: agent.isVerified,
|
||||
createdAt: agent.createdAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to get agent by user ID: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể lấy thông tin đại lý');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
||||
import { DomainException, LoggerService, NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { GetProfileQuery } from './get-profile.query';
|
||||
|
||||
@@ -21,33 +21,44 @@ export class GetProfileHandler implements IQueryHandler<GetProfileQuery> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetProfileQuery): Promise<UserProfileDto> {
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.USER_PROFILE, query.userId);
|
||||
try {
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.USER_PROFILE, query.userId);
|
||||
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const user = await this.userRepo.findById(query.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng', query.userId);
|
||||
}
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const user = await this.userRepo.findById(query.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng', query.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email?.value ?? null,
|
||||
phone: user.phone.value,
|
||||
fullName: user.fullName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
kycStatus: user.kycStatus,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
},
|
||||
CacheTTL.USER_PROFILE,
|
||||
'user_profile',
|
||||
);
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email?.value ?? null,
|
||||
phone: user.phone.value,
|
||||
fullName: user.fullName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
kycStatus: user.kycStatus,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
},
|
||||
CacheTTL.USER_PROFILE,
|
||||
'user_profile',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to get user profile: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể lấy thông tin hồ sơ người dùng');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user