fix: eliminate untyped repository returns and standardize DomainException usage across all handlers

- Create typed DTOs (ListingDetailData, ListingSearchItem, ListingSellerItem) for repository read methods
- Replace all Promise<any> and PaginatedResult<any> with concrete types in repository interface and implementation
- Remove `as any` casts in search params by using Prisma enum types (TransactionType, PropertyType)
- Migrate all 16 handlers from NestJS built-in exceptions to domain exceptions (NotFoundException, ValidationException, etc.)
- Add CONTRIBUTING.md documenting error handling convention
- All 230 tests pass, typecheck clean

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 06:25:44 +07:00
parent 6389dcf78e
commit e9889539ea
28 changed files with 288 additions and 247 deletions

92
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,92 @@
# Contributing Guide
## Error Handling Convention
### Overview
All application-layer error handling uses **domain exceptions** from `@modules/shared/domain/domain-exception`. Never import exception classes from `@nestjs/common` in handlers — use the project's own domain exceptions instead.
The `GlobalExceptionFilter` catches all exceptions and normalizes them into a consistent JSON response with structured error codes.
### Domain Exceptions
| Exception | HTTP Status | When to use |
|---|---|---|
| `NotFoundException(entity, id?)` | 404 | Entity not found in database |
| `ValidationException(message, details?)` | 400 | Invalid input, business rule violation, value object creation failure |
| `ConflictException(message)` | 409 | Duplicate resource, idempotency violation |
| `UnauthorizedException(message?)` | 401 | Invalid/expired credentials or tokens |
| `ForbiddenException(message?)` | 403 | Authenticated but not authorized for the action |
Import from:
```typescript
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
```
### Patterns by Layer
#### Command/Query Handlers
Handlers throw domain exceptions directly. No try-catch wrapping needed — the `GlobalExceptionFilter` handles uncaught exceptions.
```typescript
// Good: domain exception with entity context
const user = await this.userRepo.findById(id);
if (!user) throw new NotFoundException('User', id);
// Good: Result pattern from value objects
const phoneResult = Phone.create(command.phone);
if (phoneResult.isErr) {
throw new ValidationException(phoneResult.unwrapErr());
}
const phone = phoneResult.unwrap();
// Good: business rule validation
if (subscription.status === 'CANCELLED') {
throw new ValidationException('Subscription da bi huy');
}
```
#### Controllers
Controllers are thin delegation layers — they dispatch to the command/query bus and return the result. No error handling needed at the controller level.
#### Domain Services / Value Objects
Use the `Result<T, E>` pattern from `@modules/shared/domain/result`:
```typescript
static create(value: string): Result<Phone, string> {
if (!isValid(value)) return Result.err('So dien thoai khong hop le');
return Result.ok(new Phone({ value }));
}
```
Handlers consume `Result` by checking `.isErr` and throwing a `ValidationException`.
### What NOT to Do
```typescript
// Bad: NestJS built-in exceptions (missing errorCode in response)
import { NotFoundException } from '@nestjs/common';
// Bad: object-style exceptions (inconsistent with domain exception API)
throw new NotFoundException({ code: ErrorCode.NOT_FOUND, message: '...' });
// Bad: generic try-catch swallowing infrastructure errors as domain errors
try {
await this.prisma.plan.findFirst({ where: { tier } });
} catch {
throw new NotFoundException('Plan not found'); // hides DB connection errors
}
// Bad: returning Result from handlers to controllers
// Handlers should unwrap Result and throw on error
```
### Repository Return Types
All repository read methods must return explicitly typed DTOs — never `Promise<any>` or `PaginatedResult<any>`. Define read DTOs in the domain layer alongside the repository interface.
See `listing-read.dto.ts` for the canonical example.

View File

@@ -1,5 +1,6 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { UnauthorizedException, Inject } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { UnauthorizedException } from '@modules/shared/domain/domain-exception';
import { RefreshTokenCommand } from './refresh-token.command';
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';

View File

@@ -1,6 +1,6 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ConflictException, BadRequestException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { ConflictException, ValidationException } from '@modules/shared/domain/domain-exception';
import { createId } from '@paralleldrive/cuid2';
import { RegisterUserCommand } from './register-user.command';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
@@ -22,7 +22,7 @@ export class RegisterUserHandler implements ICommandHandler<RegisterUserCommand>
// Validate phone
const phoneResult = Phone.create(command.phone);
if (phoneResult.isErr) {
throw new BadRequestException(phoneResult.unwrapErr());
throw new ValidationException(phoneResult.unwrapErr());
}
const phone = phoneResult.unwrap();
@@ -37,7 +37,7 @@ export class RegisterUserHandler implements ICommandHandler<RegisterUserCommand>
if (command.email) {
const emailResult = Email.create(command.email);
if (emailResult.isErr) {
throw new BadRequestException(emailResult.unwrapErr());
throw new ValidationException(emailResult.unwrapErr());
}
email = emailResult.unwrap();
@@ -50,7 +50,7 @@ export class RegisterUserHandler implements ICommandHandler<RegisterUserCommand>
// Hash password
const passwordResult = await HashedPassword.fromPlain(command.password);
if (passwordResult.isErr) {
throw new BadRequestException(passwordResult.unwrapErr());
throw new ValidationException(passwordResult.unwrapErr());
}
const passwordHash = passwordResult.unwrap();

View File

@@ -1,5 +1,6 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { Inject, NotFoundException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { VerifyKycCommand } from './verify-kyc.command';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
@@ -14,7 +15,7 @@ export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
async execute(command: VerifyKycCommand): Promise<void> {
const user = await this.userRepo.findById(command.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
throw new NotFoundException('Người dùng', command.userId);
}
user.updateKycStatus(command.kycStatus, command.kycData);

View File

@@ -1,5 +1,6 @@
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject, NotFoundException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { GetProfileQuery } from './get-profile.query';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
@@ -31,7 +32,7 @@ export class GetProfileHandler implements IQueryHandler<GetProfileQuery> {
async () => {
const user = await this.userRepo.findById(query.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
throw new NotFoundException('Người dùng', query.userId);
}
return {

View File

@@ -1,5 +1,6 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject, BadRequestException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { ValidationException } from '@modules/shared/domain/domain-exception';
import { createId } from '@paralleldrive/cuid2';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { CreateListingCommand } from './create-listing.command';
@@ -29,13 +30,13 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
async execute(command: CreateListingCommand): Promise<CreateListingResult> {
// Validate value objects
const addressResult = Address.create(command.address, command.ward, command.district, command.city);
if (addressResult.isErr) throw new BadRequestException(addressResult.unwrapErr());
if (addressResult.isErr) throw new ValidationException(addressResult.unwrapErr());
const geoPointResult = GeoPoint.create(command.latitude, command.longitude);
if (geoPointResult.isErr) throw new BadRequestException(geoPointResult.unwrapErr());
if (geoPointResult.isErr) throw new ValidationException(geoPointResult.unwrapErr());
const priceResult = Price.create(command.priceVND);
if (priceResult.isErr) throw new BadRequestException(priceResult.unwrapErr());
if (priceResult.isErr) throw new ValidationException(priceResult.unwrapErr());
const address = addressResult.unwrap();
const geoPoint = geoPointResult.unwrap();

View File

@@ -1,7 +1,7 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { Inject, BadRequestException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
import { UploadMediaCommand } from './upload-media.command';
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
import { PropertyMediaEntity } from '../../../domain/entities/property-media.entity';
@@ -24,7 +24,7 @@ export class UploadMediaHandler implements ICommandHandler<UploadMediaCommand> {
const mediaCount = await this.propertyRepo.countMediaByPropertyId(command.propertyId);
if (mediaCount >= MAX_MEDIA_PER_PROPERTY) {
throw new BadRequestException(`Tối đa ${MAX_MEDIA_PER_PROPERTY} ảnh/video cho mỗi bất động sản`);
throw new ValidationException(`Tối đa ${MAX_MEDIA_PER_PROPERTY} ảnh/video cho mỗi bất động sản`);
}
const mediaType = command.file.mimetype.startsWith('video/') ? 'video' as const : 'image' as const;

View File

@@ -4,57 +4,10 @@ import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { GetListingQuery } from './get-listing.query';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { type ListingDetailData } from '../../../domain/repositories/listing-read.dto';
export interface ListingDetailDto {
id: string;
status: string;
transactionType: string;
priceVND: string;
pricePerM2: number | null;
rentPriceMonthly: string | null;
commissionPct: number | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
publishedAt: string | null;
createdAt: string;
property: {
id: string;
propertyType: string;
title: string;
description: string;
address: string;
ward: string;
district: string;
city: string;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
direction: string | null;
yearBuilt: number | null;
legalStatus: string | null;
amenities: unknown;
projectName: string | null;
media: Array<{
id: string;
url: string;
type: string;
order: number;
caption: string | null;
}>;
};
seller: {
id: string;
fullName: string;
phone: string;
};
agent: {
id: string;
userId: string;
agency: string | null;
} | null;
}
/** @deprecated Use ListingDetailData from listing-read.dto instead */
export type ListingDetailDto = ListingDetailData;
@QueryHandler(GetListingQuery)
export class GetListingHandler implements IQueryHandler<GetListingQuery> {
@@ -63,7 +16,7 @@ export class GetListingHandler implements IQueryHandler<GetListingQuery> {
private readonly cache: CacheService,
) {}
async execute(query: GetListingQuery): Promise<ListingDetailDto> {
async execute(query: GetListingQuery): Promise<ListingDetailData> {
const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);
return this.cache.getOrSet(
@@ -73,7 +26,7 @@ export class GetListingHandler implements IQueryHandler<GetListingQuery> {
if (!result) {
throw new NotFoundException('Listing', query.listingId);
}
return result as unknown as ListingDetailDto;
return result;
},
CacheTTL.LISTING_DETAIL,
'listing',

View File

@@ -2,6 +2,7 @@ import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { GetPendingModerationQuery } from './get-pending-moderation.query';
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
import { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto';
@QueryHandler(GetPendingModerationQuery)
export class GetPendingModerationHandler implements IQueryHandler<GetPendingModerationQuery> {
@@ -9,7 +10,7 @@ export class GetPendingModerationHandler implements IQueryHandler<GetPendingMode
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
) {}
async execute(query: GetPendingModerationQuery): Promise<PaginatedResult<any>> {
async execute(query: GetPendingModerationQuery): Promise<PaginatedResult<ListingSearchItem>> {
return this.listingRepo.findByStatus('PENDING_REVIEW', query.page, query.limit);
}
}

View File

@@ -2,6 +2,7 @@ import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { SearchListingsQuery } from './search-listings.query';
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
import { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto';
@QueryHandler(SearchListingsQuery)
export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery> {
@@ -9,7 +10,7 @@ export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery>
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
) {}
async execute(query: SearchListingsQuery): Promise<PaginatedResult<any>> {
async execute(query: SearchListingsQuery): Promise<PaginatedResult<ListingSearchItem>> {
return this.listingRepo.search({
status: query.status,
transactionType: query.transactionType,

View File

@@ -1,10 +1,10 @@
import { type ListingStatus } from '@prisma/client';
import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client';
export class SearchListingsQuery {
constructor(
public readonly status?: ListingStatus,
public readonly transactionType?: string,
public readonly propertyType?: string,
public readonly transactionType?: TransactionType,
public readonly propertyType?: PropertyType,
public readonly city?: string,
public readonly district?: string,
public readonly minPrice?: bigint,

View File

@@ -1,2 +1,3 @@
export { PROPERTY_REPOSITORY, type IPropertyRepository } from './property.repository';
export { LISTING_REPOSITORY, type IListingRepository, type ListingSearchParams, type PaginatedResult } from './listing.repository';
export type { ListingDetailData, ListingSearchItem, ListingSellerItem, ListingMediaData } from './listing-read.dto';

View File

@@ -0,0 +1,98 @@
import { type ListingStatus, type TransactionType, type PropertyType, type Direction } from '@prisma/client';
/** Returned by findByIdWithProperty — full listing detail with property, seller, agent */
export interface ListingDetailData {
id: string;
status: ListingStatus;
transactionType: TransactionType;
priceVND: string;
pricePerM2: number | null;
rentPriceMonthly: string | null;
commissionPct: number | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
publishedAt: string | null;
createdAt: string;
property: {
id: string;
propertyType: PropertyType;
title: string;
description: string;
address: string;
ward: string;
district: string;
city: string;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
direction: Direction | null;
yearBuilt: number | null;
legalStatus: string | null;
amenities: unknown;
projectName: string | null;
media: ListingMediaData[];
};
seller: {
id: string;
fullName: string;
phone: string;
};
agent: {
id: string;
userId: string;
agency: string | null;
} | null;
}
export interface ListingMediaData {
id: string;
url: string;
type: string;
order: number;
caption: string | null;
}
/** Returned by search / findByStatus — listing summary with thumbnail */
export interface ListingSearchItem {
id: string;
status: ListingStatus;
transactionType: TransactionType;
priceVND: string;
pricePerM2: number | null;
viewCount: number;
publishedAt: string | null;
property: {
id: string;
propertyType: PropertyType;
title: string;
address: string;
district: string;
city: string;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
thumbnail: string | null;
};
seller: {
id: string;
fullName: string;
};
}
/** Returned by findBySellerId — compact listing for seller dashboard */
export interface ListingSellerItem {
id: string;
status: ListingStatus;
transactionType: TransactionType;
priceVND: string;
property: {
id: string;
title: string;
district: string;
city: string;
areaM2: number;
thumbnail: string | null;
};
}

View File

@@ -1,12 +1,13 @@
import { type ListingStatus } from '@prisma/client';
import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client';
import { type ListingEntity } from '../entities/listing.entity';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from './listing-read.dto';
export const LISTING_REPOSITORY = Symbol('LISTING_REPOSITORY');
export interface ListingSearchParams {
status?: ListingStatus;
transactionType?: string;
propertyType?: string;
transactionType?: TransactionType;
propertyType?: PropertyType;
city?: string;
district?: string;
minPrice?: bigint;
@@ -28,10 +29,10 @@ export interface PaginatedResult<T> {
export interface IListingRepository {
findById(id: string): Promise<ListingEntity | null>;
findByIdWithProperty(id: string): Promise<{ listing: ListingEntity; property: any } | null>;
findByIdWithProperty(id: string): Promise<ListingDetailData | null>;
save(listing: ListingEntity): Promise<void>;
update(listing: ListingEntity): Promise<void>;
search(params: ListingSearchParams): Promise<PaginatedResult<any>>;
findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<any>>;
findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<any>>;
search(params: ListingSearchParams): Promise<PaginatedResult<ListingSearchItem>>;
findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<ListingSearchItem>>;
findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<ListingSellerItem>>;
}

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type Listing as PrismaListing, type Prisma, type ListingStatus } from '@prisma/client';
import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
import { Price } from '../../domain/value-objects/price.vo';
@@ -14,7 +15,7 @@ export class PrismaListingRepository implements IListingRepository {
return listing ? this.toDomain(listing) : null;
}
async findByIdWithProperty(id: string): Promise<any | null> {
async findByIdWithProperty(id: string): Promise<ListingDetailData | null> {
const listing = await this.prisma.listing.findUnique({
where: { id },
include: {
@@ -122,7 +123,7 @@ export class PrismaListingRepository implements IListingRepository {
});
}
async search(params: ListingSearchParams): Promise<PaginatedResult<any>> {
async search(params: ListingSearchParams): Promise<PaginatedResult<ListingSearchItem>> {
const page = params.page ?? 1;
const limit = Math.min(params.limit ?? 20, 100);
const skip = (page - 1) * limit;
@@ -130,7 +131,7 @@ export class PrismaListingRepository implements IListingRepository {
const where: Prisma.ListingWhereInput = {};
if (params.status) where.status = params.status;
if (params.transactionType) where.transactionType = params.transactionType as any;
if (params.transactionType) where.transactionType = params.transactionType;
if (params.minPrice || params.maxPrice) {
where.priceVND = {};
if (params.minPrice) where.priceVND.gte = params.minPrice;
@@ -139,7 +140,7 @@ export class PrismaListingRepository implements IListingRepository {
if (params.propertyType || params.city || params.district || params.minArea || params.maxArea || params.bedrooms) {
where.property = {};
if (params.propertyType) where.property.propertyType = params.propertyType as any;
if (params.propertyType) where.property.propertyType = params.propertyType;
if (params.city) where.property.city = params.city;
if (params.district) where.property.district = params.district;
if (params.minArea || params.maxArea) {
@@ -198,11 +199,11 @@ export class PrismaListingRepository implements IListingRepository {
};
}
async findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<any>> {
async findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<ListingSearchItem>> {
return this.search({ status, page, limit });
}
async findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<any>> {
async findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<ListingSellerItem>> {
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = { sellerId };

View File

@@ -39,8 +39,8 @@ import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
import { ModerateListingDto } from '../dto/moderate-listing.dto';
import { SearchListingsDto } from '../dto/search-listings.dto';
import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
import { type ListingDetailDto } from '../../application/queries/get-listing/get-listing.handler';
import { type PaginatedResult } from '../../domain/repositories/listing.repository';
import { type ListingDetailData, type ListingSearchItem } from '../../domain/repositories/listing-read.dto';
@ApiTags('listings')
@Controller('listings')
@@ -109,7 +109,7 @@ export class ListingsController {
async getPendingModeration(
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<PaginatedResult<any>> {
): Promise<PaginatedResult<ListingSearchItem>> {
return this.queryBus.execute(
new GetPendingModerationQuery(page ?? 1, limit ?? 20),
);
@@ -120,14 +120,14 @@ export class ListingsController {
@ApiResponse({ status: 200, description: 'Listing details returned' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@Get(':id')
async getListing(@Param('id') id: string): Promise<ListingDetailDto> {
async getListing(@Param('id') id: string): Promise<ListingDetailData> {
return this.queryBus.execute(new GetListingQuery(id));
}
@ApiOperation({ summary: 'Search and filter property listings' })
@ApiResponse({ status: 200, description: 'Paginated search results' })
@Get()
async searchListings(@Query() dto: SearchListingsDto): Promise<PaginatedResult<any>> {
async searchListings(@Query() dto: SearchListingsDto): Promise<PaginatedResult<ListingSearchItem>> {
return this.queryBus.execute(
new SearchListingsQuery(
dto.status,

View File

@@ -1,11 +1,7 @@
import {
BadRequestException,
ConflictException,
Inject,
Logger,
} from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { ConflictException, ValidationException } from '@modules/shared/domain/domain-exception';
import { CreatePaymentCommand } from './create-payment.command';
import {
PAYMENT_REPOSITORY,
@@ -17,7 +13,6 @@ import {
} from '../../../infrastructure/services/payment-gateway.interface';
import { PaymentEntity } from '../../../domain/entities/payment.entity';
import { Money } from '../../../domain/value-objects/money.vo';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface CreatePaymentResult {
paymentId: string;
@@ -43,26 +38,16 @@ export class CreatePaymentHandler implements ICommandHandler<CreatePaymentComman
const existing = await this.paymentRepo.findByIdempotencyKey(command.idempotencyKey);
if (existing) {
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
throw new ConflictException({
code: ErrorCode.PAYMENT_ALREADY_PROCESSED,
message: 'Thanh toán với idempotency key này đã tồn tại',
paymentId: existing.id,
});
throw new ConflictException('Thanh toán với idempotency key này đã tồn tại');
}
throw new ConflictException({
code: ErrorCode.PAYMENT_ALREADY_PROCESSED,
message: 'Thanh toán đã được xử lý',
});
throw new ConflictException('Thanh toán đã được xử lý');
}
}
// Validate amount
const moneyResult = Money.create(command.amountVND);
if (moneyResult.isErr) {
throw new BadRequestException({
code: ErrorCode.PAYMENT_INVALID_AMOUNT,
message: moneyResult.unwrapErr(),
});
throw new ValidationException(moneyResult.unwrapErr());
}
const money = moneyResult.unwrap();

View File

@@ -1,10 +1,6 @@
import {
BadRequestException,
Inject,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
import { HandleCallbackCommand } from './handle-callback.command';
import {
PAYMENT_REPOSITORY,
@@ -14,7 +10,6 @@ import {
PAYMENT_GATEWAY_FACTORY,
type IPaymentGatewayFactory,
} from '../../../infrastructure/services/payment-gateway.interface';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface HandleCallbackResult {
paymentId: string;
@@ -42,10 +37,7 @@ export class HandleCallbackHandler implements ICommandHandler<HandleCallbackComm
this.logger.warn(
`Invalid callback signature for provider=${command.provider}`,
);
throw new BadRequestException({
code: ErrorCode.PAYMENT_FAILED,
message: 'Chữ ký callback không hợp lệ',
});
throw new ValidationException('Chữ ký callback không hợp lệ');
}
// Atomically transition payment status to prevent race conditions
@@ -65,10 +57,7 @@ export class HandleCallbackHandler implements ICommandHandler<HandleCallbackComm
const existing = await this.paymentRepo.findById(result.orderId);
if (!existing) {
this.logger.warn(`Payment not found for orderId=${result.orderId}`);
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy thanh toán',
});
throw new NotFoundException('Payment', result.orderId);
}
// Already processed — return idempotent response

View File

@@ -1,10 +1,6 @@
import {
BadRequestException,
Inject,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
import { RefundPaymentCommand } from './refund-payment.command';
import {
PAYMENT_REPOSITORY,
@@ -14,7 +10,6 @@ import {
PAYMENT_GATEWAY_FACTORY,
type IPaymentGatewayFactory,
} from '../../../infrastructure/services/payment-gateway.interface';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface RefundPaymentResult {
paymentId: string;
@@ -36,24 +31,15 @@ export class RefundPaymentHandler implements ICommandHandler<RefundPaymentComman
async execute(command: RefundPaymentCommand): Promise<RefundPaymentResult> {
const payment = await this.paymentRepo.findById(command.paymentId);
if (!payment) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy thanh toán',
});
throw new NotFoundException('Payment', command.paymentId);
}
if (payment.status !== 'COMPLETED') {
throw new BadRequestException({
code: ErrorCode.PAYMENT_FAILED,
message: 'Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất',
});
throw new ValidationException('Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất');
}
if (!payment.providerTxId) {
throw new BadRequestException({
code: ErrorCode.PAYMENT_FAILED,
message: 'Không có mã giao dịch từ nhà cung cấp',
});
throw new ValidationException('Không có mã giao dịch từ nhà cung cấp');
}
const gateway = this.gatewayFactory.getGateway(payment.provider);

View File

@@ -1,15 +1,11 @@
import {
ForbiddenException,
Inject,
NotFoundException,
} from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { NotFoundException, ForbiddenException } from '@modules/shared/domain/domain-exception';
import { GetPaymentStatusQuery } from './get-payment-status.query';
import {
PAYMENT_REPOSITORY,
type IPaymentRepository,
} from '../../../domain/repositories/payment.repository';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface PaymentStatusDto {
id: string;
@@ -32,17 +28,11 @@ export class GetPaymentStatusHandler implements IQueryHandler<GetPaymentStatusQu
async execute(query: GetPaymentStatusQuery): Promise<PaymentStatusDto> {
const payment = await this.paymentRepo.findById(query.paymentId);
if (!payment) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy thanh toán',
});
throw new NotFoundException('Payment', query.paymentId);
}
if (payment.userId !== query.userId) {
throw new ForbiddenException({
code: ErrorCode.FORBIDDEN,
message: 'Bạn không có quyền xem thanh toán này',
});
throw new ForbiddenException('Bạn không có quyền xem thanh toán này');
}
return {

View File

@@ -1,4 +1,4 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { ConflictException, NotFoundException } from '@modules/shared/domain/domain-exception';
import { EventBus } from '@nestjs/cqrs';
import { CreateSubscriptionHandler } from '../commands/create-subscription/create-subscription.handler';
import { CreateSubscriptionCommand } from '../commands/create-subscription/create-subscription.command';

View File

@@ -1,11 +1,6 @@
import {
BadRequestException,
Inject,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
import { CancelSubscriptionCommand } from './cancel-subscription.command';
import {
SUBSCRIPTION_REPOSITORY,
@@ -31,17 +26,11 @@ export class CancelSubscriptionHandler implements ICommandHandler<CancelSubscrip
async execute(command: CancelSubscriptionCommand): Promise<CancelSubscriptionResult> {
const subscription = await this.subscriptionRepo.findByUserId(command.userId);
if (!subscription) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy subscription',
});
throw new NotFoundException('Subscription', command.userId);
}
if (subscription.status === 'CANCELLED') {
throw new BadRequestException({
code: ErrorCode.BAD_REQUEST,
message: 'Subscription đã bị hủy trước đó',
});
throw new ValidationException('Subscription đã bị hủy trước đó');
}
subscription.cancel();

View File

@@ -1,14 +1,8 @@
import {
BadRequestException,
ConflictException,
Inject,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { NotFoundException, ConflictException } from '@modules/shared/domain/domain-exception';
import { CreateSubscriptionCommand } from './create-subscription.command';
import {
SUBSCRIPTION_REPOSITORY,
@@ -39,10 +33,7 @@ export class CreateSubscriptionHandler implements ICommandHandler<CreateSubscrip
// Check if user already has an active subscription
const existing = await this.subscriptionRepo.findByUserId(command.userId);
if (existing && (existing.status === 'ACTIVE' || existing.status === 'PAST_DUE')) {
throw new ConflictException({
code: ErrorCode.CONFLICT,
message: 'Người dùng đã có subscription đang hoạt động',
});
throw new ConflictException('Người dùng đã có subscription đang hoạt động');
}
// Fetch plan
@@ -50,10 +41,7 @@ export class CreateSubscriptionHandler implements ICommandHandler<CreateSubscrip
where: { tier: command.planTier, isActive: true },
});
if (!plan) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy gói đăng ký',
});
throw new NotFoundException('Plan', command.planTier);
}
// Calculate period

View File

@@ -1,12 +1,7 @@
import {
BadRequestException,
Inject,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
import { MeterUsageCommand } from './meter-usage.command';
import {
SUBSCRIPTION_REPOSITORY,
@@ -33,25 +28,16 @@ export class MeterUsageHandler implements ICommandHandler<MeterUsageCommand> {
async execute(command: MeterUsageCommand): Promise<MeterUsageResult> {
if (command.count <= 0) {
throw new BadRequestException({
code: ErrorCode.BAD_REQUEST,
message: 'Số lượng phải lớn hơn 0',
});
throw new ValidationException('Số lượng phải lớn hơn 0');
}
const subscription = await this.subscriptionRepo.findByUserId(command.userId);
if (!subscription) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy subscription',
});
throw new NotFoundException('Subscription', command.userId);
}
if (!subscription.isActive()) {
throw new BadRequestException({
code: ErrorCode.BAD_REQUEST,
message: 'Subscription không ở trạng thái hoạt động',
});
throw new ValidationException('Subscription không ở trạng thái hoạt động');
}
// Upsert usage record for current period + metric

View File

@@ -1,12 +1,7 @@
import {
BadRequestException,
Inject,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
import { UpgradeSubscriptionCommand } from './upgrade-subscription.command';
import {
SUBSCRIPTION_REPOSITORY,
@@ -36,17 +31,11 @@ export class UpgradeSubscriptionHandler implements ICommandHandler<UpgradeSubscr
async execute(command: UpgradeSubscriptionCommand): Promise<UpgradeSubscriptionResult> {
const subscription = await this.subscriptionRepo.findByUserId(command.userId);
if (!subscription) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy subscription',
});
throw new NotFoundException('Subscription', command.userId);
}
if (!subscription.isActive()) {
throw new BadRequestException({
code: ErrorCode.BAD_REQUEST,
message: 'Subscription không ở trạng thái hoạt động',
});
throw new ValidationException('Subscription không ở trạng thái hoạt động');
}
// Validate upgrade direction
@@ -55,18 +44,12 @@ export class UpgradeSubscriptionHandler implements ICommandHandler<UpgradeSubscr
if (newOrder <= currentOrder && command.newPlanTier !== subscription.planTier) {
// Allow same-tier for AGENT_PRO <-> INVESTOR switches
if (currentOrder !== newOrder) {
throw new BadRequestException({
code: ErrorCode.BAD_REQUEST,
message: 'Chỉ có thể nâng cấp lên gói cao hơn',
});
throw new ValidationException('Chỉ có thể nâng cấp lên gói cao hơn');
}
}
if (command.newPlanTier === subscription.planTier) {
throw new BadRequestException({
code: ErrorCode.BAD_REQUEST,
message: 'Đã đang sử dụng gói này',
});
throw new ValidationException('Đã đang sử dụng gói này');
}
// Fetch new plan
@@ -74,10 +57,7 @@ export class UpgradeSubscriptionHandler implements ICommandHandler<UpgradeSubscr
where: { tier: command.newPlanTier, isActive: true },
});
if (!newPlan) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy gói đăng ký mới',
});
throw new NotFoundException('Plan', command.newPlanTier);
}
const previousTier = subscription.planTier;

View File

@@ -1,7 +1,7 @@
import { Inject, NotFoundException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CheckQuotaQuery } from './check-quota.query';
import {
SUBSCRIPTION_REPOSITORY,
@@ -47,10 +47,7 @@ export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
where: { id: subscription.planId },
});
if (!plan) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy plan',
});
throw new NotFoundException('Plan', subscription.planId);
}
return this.checkAgainstPlan(plan, query.metric, subscription.id, query.userId);

View File

@@ -1,7 +1,6 @@
import { Inject, NotFoundException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { GetBillingHistoryQuery } from './get-billing-history.query';
import {
SUBSCRIPTION_REPOSITORY,

View File

@@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { GetPlanQuery } from './get-plan.query';
@@ -27,9 +27,9 @@ export class GetPlanHandler implements IQueryHandler<GetPlanQuery> {
where: { tier: query.planTier, isActive: true },
});
} catch {
throw new NotFoundException(`Plan tier "${query.planTier}" not found`);
throw new NotFoundException('Plan', query.planTier);
}
if (!plan) throw new NotFoundException(`Plan tier "${query.planTier}" not found`);
if (!plan) throw new NotFoundException('Plan', query.planTier);
return this.toDto(plan);
}