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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user