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:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,6 +293,7 @@ export class ListingsController {
|
||||
dto.limit,
|
||||
dto.sortBy,
|
||||
dto.newSince != null ? new Date(dto.newSince) : undefined,
|
||||
dto.order,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user