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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 12:13:10 +07:00
parent 7b6e99edef
commit ef1bdcad1c
7 changed files with 87 additions and 7 deletions

View File

@@ -29,6 +29,9 @@ export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery>
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<SearchListingsQuery>
bedrooms: query.bedrooms,
page: query.page,
limit: query.limit,
sortBy: query.sortBy,
newSince: query.newSince,
order: query.order,
}),
CacheTTL.SEARCH_RESULTS,
'listing_search',

View File

@@ -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,
) {}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -293,6 +293,7 @@ export class ListingsController {
dto.limit,
dto.sortBy,
dto.newSince != null ? new Date(dto.newSince) : undefined,
dto.order,
),
);
}

View File

@@ -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;
}