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

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,