From e9889539ea7929645224709ef67643c7eda9ed4c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 06:25:44 +0700 Subject: [PATCH] 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 and PaginatedResult 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 --- CONTRIBUTING.md | 92 +++++++++++++++++ .../refresh-token/refresh-token.handler.ts | 3 +- .../register-user/register-user.handler.ts | 8 +- .../commands/verify-kyc/verify-kyc.handler.ts | 5 +- .../get-profile/get-profile.handler.ts | 5 +- .../create-listing/create-listing.handler.ts | 9 +- .../upload-media/upload-media.handler.ts | 6 +- .../get-listing/get-listing.handler.ts | 57 +---------- .../get-pending-moderation.handler.ts | 3 +- .../search-listings.handler.ts | 3 +- .../search-listings/search-listings.query.ts | 6 +- .../listings/domain/repositories/index.ts | 1 + .../domain/repositories/listing-read.dto.ts | 98 +++++++++++++++++++ .../domain/repositories/listing.repository.ts | 15 +-- .../repositories/prisma-listing.repository.ts | 13 +-- .../controllers/listings.controller.ts | 8 +- .../create-payment/create-payment.handler.ts | 25 +---- .../handle-callback.handler.ts | 19 +--- .../refund-payment/refund-payment.handler.ts | 24 +---- .../get-payment-status.handler.ts | 18 +--- .../create-subscription.handler.spec.ts | 2 +- .../cancel-subscription.handler.ts | 19 +--- .../create-subscription.handler.ts | 20 +--- .../meter-usage/meter-usage.handler.ts | 24 +---- .../upgrade-subscription.handler.ts | 34 ++----- .../check-quota/check-quota.handler.ts | 9 +- .../get-billing-history.handler.ts | 3 +- .../queries/get-plan/get-plan.handler.ts | 6 +- 28 files changed, 288 insertions(+), 247 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4fa4aa5 --- /dev/null +++ b/CONTRIBUTING.md @@ -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` pattern from `@modules/shared/domain/result`: + +```typescript +static create(value: string): Result { + 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` or `PaginatedResult`. Define read DTOs in the domain layer alongside the repository interface. + +See `listing-read.dto.ts` for the canonical example. diff --git a/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts index efc8370..93defb3 100644 --- a/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts +++ b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts @@ -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'; diff --git a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts index 17845f7..4397310 100644 --- a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts +++ b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts @@ -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 // 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 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 // 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(); diff --git a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts index 02d7a0b..6fb3714 100644 --- a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts +++ b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts @@ -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 { async execute(command: VerifyKycCommand): Promise { 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); diff --git a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts index ca5bf00..7e54d55 100644 --- a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts +++ b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts @@ -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 { 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 { diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts index 3b2630f..f6dea81 100644 --- a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts @@ -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 { // 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(); diff --git a/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts b/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts index 6c3e867..7f053b5 100644 --- a/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts +++ b/apps/api/src/modules/listings/application/commands/upload-media/upload-media.handler.ts @@ -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 { 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; diff --git a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts index da6a528..51a5c57 100644 --- a/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts +++ b/apps/api/src/modules/listings/application/queries/get-listing/get-listing.handler.ts @@ -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 { @@ -63,7 +16,7 @@ export class GetListingHandler implements IQueryHandler { private readonly cache: CacheService, ) {} - async execute(query: GetListingQuery): Promise { + async execute(query: GetListingQuery): Promise { const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId); return this.cache.getOrSet( @@ -73,7 +26,7 @@ export class GetListingHandler implements IQueryHandler { if (!result) { throw new NotFoundException('Listing', query.listingId); } - return result as unknown as ListingDetailDto; + return result; }, CacheTTL.LISTING_DETAIL, 'listing', diff --git a/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts b/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts index 0339366..95a9e24 100644 --- a/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts +++ b/apps/api/src/modules/listings/application/queries/get-pending-moderation/get-pending-moderation.handler.ts @@ -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 { @@ -9,7 +10,7 @@ export class GetPendingModerationHandler implements IQueryHandler> { + async execute(query: GetPendingModerationQuery): Promise> { return this.listingRepo.findByStatus('PENDING_REVIEW', query.page, query.limit); } } diff --git a/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts index fa35e55..021b5cf 100644 --- a/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts +++ b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.handler.ts @@ -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 { @@ -9,7 +10,7 @@ export class SearchListingsHandler implements IQueryHandler @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, ) {} - async execute(query: SearchListingsQuery): Promise> { + async execute(query: SearchListingsQuery): Promise> { return this.listingRepo.search({ status: query.status, transactionType: query.transactionType, diff --git a/apps/api/src/modules/listings/application/queries/search-listings/search-listings.query.ts b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.query.ts index f3e581a..cdf186f 100644 --- a/apps/api/src/modules/listings/application/queries/search-listings/search-listings.query.ts +++ b/apps/api/src/modules/listings/application/queries/search-listings/search-listings.query.ts @@ -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, diff --git a/apps/api/src/modules/listings/domain/repositories/index.ts b/apps/api/src/modules/listings/domain/repositories/index.ts index c408ee6..c47e908 100644 --- a/apps/api/src/modules/listings/domain/repositories/index.ts +++ b/apps/api/src/modules/listings/domain/repositories/index.ts @@ -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'; diff --git a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts new file mode 100644 index 0000000..3865c85 --- /dev/null +++ b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts @@ -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; + }; +} diff --git a/apps/api/src/modules/listings/domain/repositories/listing.repository.ts b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts index f6c7aa6..8390dd8 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing.repository.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts @@ -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 { export interface IListingRepository { findById(id: string): Promise; - findByIdWithProperty(id: string): Promise<{ listing: ListingEntity; property: any } | null>; + findByIdWithProperty(id: string): Promise; save(listing: ListingEntity): Promise; update(listing: ListingEntity): Promise; - search(params: ListingSearchParams): Promise>; - findByStatus(status: ListingStatus, page: number, limit: number): Promise>; - findBySellerId(sellerId: string, page: number, limit: number): Promise>; + search(params: ListingSearchParams): Promise>; + findByStatus(status: ListingStatus, page: number, limit: number): Promise>; + findBySellerId(sellerId: string, page: number, limit: number): Promise>; } diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts index 6d4dd51..9abbb16 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts @@ -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 { + async findByIdWithProperty(id: string): Promise { const listing = await this.prisma.listing.findUnique({ where: { id }, include: { @@ -122,7 +123,7 @@ export class PrismaListingRepository implements IListingRepository { }); } - async search(params: ListingSearchParams): Promise> { + async search(params: ListingSearchParams): Promise> { 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> { + async findByStatus(status: ListingStatus, page: number, limit: number): Promise> { return this.search({ status, page, limit }); } - async findBySellerId(sellerId: string, page: number, limit: number): Promise> { + async findBySellerId(sellerId: string, page: number, limit: number): Promise> { const skip = (page - 1) * limit; const where: Prisma.ListingWhereInput = { sellerId }; diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 27ce0f6..91d53ec 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -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> { + ): Promise> { 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 { + async getListing(@Param('id') id: string): Promise { 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> { + async searchListings(@Query() dto: SearchListingsDto): Promise> { return this.queryBus.execute( new SearchListingsQuery( dto.status, diff --git a/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts b/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts index 9e16962..41ad054 100644 --- a/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts +++ b/apps/api/src/modules/payments/application/commands/create-payment/create-payment.handler.ts @@ -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 { 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); diff --git a/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts index fdbb872..630c6f9 100644 --- a/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts +++ b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts @@ -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 { 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 { diff --git a/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts index 3a864f0..3566b27 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/create-subscription.handler.spec.ts @@ -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'; diff --git a/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts index 9b40ff0..1cc19cf 100644 --- a/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts @@ -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 { 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(); diff --git a/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts index 082560f..20452b4 100644 --- a/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts @@ -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 { async execute(command: MeterUsageCommand): Promise { 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 diff --git a/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts index 9a07a41..22850da 100644 --- a/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts @@ -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 { 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 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 { 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); diff --git a/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts b/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts index 328b021..74f309b 100644 --- a/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts @@ -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, diff --git a/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts index 95da52c..64ef543 100644 --- a/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts @@ -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 { 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); }