From ef1bdcad1cae4383bb72b54d73e0966d3846e18a Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 21 Apr 2026 12:13:10 +0700 Subject: [PATCH] fix(listings): add 'order' param to SearchListingsDto (TEC-3088) FE sends ?sortBy=publishedAt&order=desc on /listings and was getting 400 "property order should not exist". Add optional order ('asc'|'desc') to the DTO, plumb through query/handler/cache key, and apply direction in the Prisma orderBy. priceAsc/priceDesc still encode their own default direction but honour an explicit order override. Co-Authored-By: Paperclip --- .../search-listings.handler.ts | 6 ++++ .../search-listings/search-listings.query.ts | 4 +++ .../domain/repositories/listing.repository.ts | 5 ++- .../repositories/listing-read.queries.ts | 14 +++++--- .../__tests__/search-listings.dto.spec.ts | 30 ++++++++++++++++ .../controllers/listings.controller.ts | 1 + .../presentation/dto/search-listings.dto.ts | 34 ++++++++++++++++++- 7 files changed, 87 insertions(+), 7 deletions(-) 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 e081e0e..d4d7016 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 @@ -29,6 +29,9 @@ export class SearchListingsHandler implements IQueryHandler query.bedrooms?.toString(), String(query.page), String(query.limit), + query.sortBy, + query.newSince?.toISOString(), + query.order, ); return this.cacheService.getOrSet( @@ -47,6 +50,9 @@ export class SearchListingsHandler implements IQueryHandler bedrooms: query.bedrooms, page: query.page, limit: query.limit, + sortBy: query.sortBy, + newSince: query.newSince, + order: query.order, }), CacheTTL.SEARCH_RESULTS, 'listing_search', 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 cdf186f..2877a20 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,4 +1,5 @@ import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client'; +import { type ListingSortBy, type ListingSortOrder } from '../../../domain/repositories/listing.repository'; export class SearchListingsQuery { constructor( @@ -14,5 +15,8 @@ export class SearchListingsQuery { public readonly bedrooms?: number, public readonly page: number = 1, public readonly limit: number = 20, + public readonly sortBy?: ListingSortBy, + public readonly newSince?: Date, + public readonly order?: ListingSortOrder, ) {} } 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 5e680fb..7210b17 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing.repository.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing.repository.ts @@ -5,6 +5,7 @@ import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem, export const LISTING_REPOSITORY = Symbol('LISTING_REPOSITORY'); export type ListingSortBy = 'publishedAt' | 'priceAsc' | 'priceDesc' | 'createdAt'; +export type ListingSortOrder = 'asc' | 'desc'; export interface ListingSearchParams { status?: ListingStatus; @@ -19,8 +20,10 @@ export interface ListingSearchParams { bedrooms?: number; page?: number; limit?: number; - /** Sort field + direction. Defaults to publishedAt DESC with featured listings first. */ + /** Sort field. Defaults to publishedAt with featured listings first. */ sortBy?: ListingSortBy; + /** Sort direction (asc | desc). Defaults to desc. */ + order?: ListingSortOrder; /** Return only listings with publishedAt > newSince (delta pull for FE ticker). */ newSince?: Date; } diff --git a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts index ecdaa43..7f8b569 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts @@ -169,25 +169,29 @@ export async function searchListings( where.publishedAt = { gt: params.newSince }; } - // Build orderBy based on sortBy param + // Build orderBy based on sortBy + order params type OrderByClause = Prisma.ListingOrderByWithRelationInput; const sortBy = params.sortBy ?? 'publishedAt'; + // Default direction depends on sortBy: priceAsc/priceDesc encode their own direction; + // publishedAt/createdAt default to desc; explicit `order` overrides where applicable. + const order: 'asc' | 'desc' = params.order === 'asc' ? 'asc' : params.order === 'desc' ? 'desc' : 'desc'; let sortClauses: OrderByClause[]; switch (sortBy) { case 'priceAsc': - sortClauses = [{ priceVND: 'asc' }]; + // sortBy already pins direction; allow override only if explicitly set + sortClauses = [{ priceVND: params.order ?? 'asc' }]; break; case 'priceDesc': - sortClauses = [{ priceVND: 'desc' }]; + sortClauses = [{ priceVND: params.order ?? 'desc' }]; break; case 'createdAt': - sortClauses = [{ createdAt: 'desc' }]; + sortClauses = [{ createdAt: order }]; break; case 'publishedAt': default: sortClauses = [ { featuredUntil: { sort: 'desc', nulls: 'last' } }, - { publishedAt: { sort: 'desc', nulls: 'last' } }, + { publishedAt: { sort: order, nulls: 'last' } }, ]; break; } diff --git a/apps/api/src/modules/listings/presentation/__tests__/search-listings.dto.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/search-listings.dto.spec.ts index 24da9db..1f30c7f 100644 --- a/apps/api/src/modules/listings/presentation/__tests__/search-listings.dto.spec.ts +++ b/apps/api/src/modules/listings/presentation/__tests__/search-listings.dto.spec.ts @@ -74,4 +74,34 @@ describe('SearchListingsDto', () => { expect(dto.maxArea).toBe(200); expect(dto.bedrooms).toBe(2); }); + + it('should accept order=desc alongside sortBy=publishedAt (TEC-3088)', async () => { + const dto = plainToInstance(SearchListingsDto, { + page: 1, + limit: 50, + status: 'ACTIVE', + sortBy: 'publishedAt', + order: 'desc', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.sortBy).toBe('publishedAt'); + expect(dto.order).toBe('desc'); + }); + + it('should accept order=asc', async () => { + const dto = plainToInstance(SearchListingsDto, { order: 'asc' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.order).toBe('asc'); + }); + + it('should reject invalid order value', async () => { + const dto = plainToInstance(SearchListingsDto, { order: 'sideways' }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const orderError = errors.find((e) => e.property === 'order'); + expect(orderError).toBeDefined(); + }); }); 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 5ed2b6a..c26ec2e 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -293,6 +293,7 @@ export class ListingsController { dto.limit, dto.sortBy, dto.newSince != null ? new Date(dto.newSince) : undefined, + dto.order, ), ); } diff --git a/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts b/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts index da27c8c..03e9ca8 100644 --- a/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts @@ -1,7 +1,12 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { ListingStatus, PropertyType, TransactionType } from '@prisma/client'; import { Transform, Type } from 'class-transformer'; -import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsEnum, IsIn, IsISO8601, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { type ListingSortBy } from '../../domain/repositories/listing.repository'; + +const LISTING_SORT_BY_VALUES: ListingSortBy[] = ['publishedAt', 'priceAsc', 'priceDesc', 'createdAt']; +const LISTING_SORT_ORDER_VALUES = ['asc', 'desc'] as const; +export type ListingSortOrder = (typeof LISTING_SORT_ORDER_VALUES)[number]; export class SearchListingsDto { @ApiPropertyOptional({ enum: ListingStatus, example: 'ACTIVE', description: 'Filter by listing status' }) @@ -71,4 +76,31 @@ export class SearchListingsDto { @Min(1) @Max(100) limit?: number; + + @ApiPropertyOptional({ + enum: LISTING_SORT_BY_VALUES, + example: 'publishedAt', + description: 'Sort field. Defaults to publishedAt with featured listings first.', + }) + @IsOptional() + @IsIn(LISTING_SORT_BY_VALUES) + sortBy?: ListingSortBy; + + @ApiPropertyOptional({ + enum: LISTING_SORT_ORDER_VALUES, + example: 'desc', + description: 'Sort direction (asc | desc). Defaults to desc.', + }) + @IsOptional() + @IsIn(LISTING_SORT_ORDER_VALUES) + order?: ListingSortOrder; + + @ApiPropertyOptional({ + type: String, + example: '2026-04-21T00:00:00.000Z', + description: 'Return only listings with publishedAt > newSince (ISO-8601 timestamp). Used for delta pulls by the FE ticker.', + }) + @IsOptional() + @IsISO8601() + newSince?: string; }