feat(listings): R2.3 featured listings entitlement + admin promote + search filter (TEC-2754)
- Add Plan.featuredListingsQuota (Int?) with per-tier seed (FREE=0, AGENT_PRO=5, INVESTOR=10, ENTERPRISE unlimited) and migration 20260418000000_add_featured_listings_quota
- Wire featured_listings_promoted metric into CheckQuotaHandler METRIC_TO_PLAN_FIELD so QuotaGuard honors the new quota
- Add PromoteFeaturedListingCommand + handler (entitlement-based, no payment): verifies ownership/agent, checks quota, extends featuredUntil, meters usage
- Add POST /listings/:id/promote endpoint gated by @RequireQuota('featured_listings_promoted') + QuotaGuard
- Add AdminFeatureListingCommand + handler with LISTING_FEATURED / LISTING_UNFEATURED audit log entries (new AdminAction enum values) and transactional write
- Add POST /admin/moderation/listings/:id/feature endpoint (ADMIN-only) with reason + duration
- Expose featured?: boolean filter on SearchPropertiesDto -> isFeatured:=1|0 Typesense filter in SearchPropertiesHandler
- Unit tests: 8 for PromoteFeaturedListingHandler, 6 for AdminFeatureListingHandler, 3 for search featured filter
Keeps existing pay-per-feature FeatureListingHandler intact for backward compatibility.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -107,4 +107,41 @@ describe('SearchPropertiesHandler', () => {
|
||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||
expect(searchCall.filterBy).toContain('areaM2:<=200');
|
||||
});
|
||||
|
||||
it('applies featured=true filter as isFeatured:=1', async () => {
|
||||
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||
|
||||
const query = new SearchPropertiesQuery(
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, true,
|
||||
);
|
||||
await handler.execute(query);
|
||||
|
||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||
expect(searchCall.filterBy).toContain('isFeatured:=1');
|
||||
});
|
||||
|
||||
it('applies featured=false filter as isFeatured:=0', async () => {
|
||||
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||
|
||||
const query = new SearchPropertiesQuery(
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, false,
|
||||
);
|
||||
await handler.execute(query);
|
||||
|
||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||
expect(searchCall.filterBy).toContain('isFeatured:=0');
|
||||
});
|
||||
|
||||
it('omits isFeatured filter when featured is undefined', async () => {
|
||||
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||
|
||||
await handler.execute(new SearchPropertiesQuery('anything'));
|
||||
|
||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||
expect(searchCall.filterBy).not.toContain('isFeatured');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,11 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
||||
if (query.city) {
|
||||
filters.push(`city:=${query.city}`);
|
||||
}
|
||||
if (query.featured === true) {
|
||||
filters.push(`isFeatured:=1`);
|
||||
} else if (query.featured === false) {
|
||||
filters.push(`isFeatured:=0`);
|
||||
}
|
||||
|
||||
const searchParams = {
|
||||
query: query.query,
|
||||
@@ -73,6 +78,7 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
||||
query.areaMax,
|
||||
query.bedrooms,
|
||||
query.sortBy,
|
||||
query.featured === undefined ? undefined : String(query.featured),
|
||||
);
|
||||
|
||||
return this.cache.getOrSet(
|
||||
|
||||
@@ -13,5 +13,6 @@ export class SearchPropertiesQuery {
|
||||
public readonly sortBy?: string,
|
||||
public readonly page?: number,
|
||||
public readonly perPage?: number,
|
||||
public readonly featured?: boolean,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export class SearchController {
|
||||
dto.sortBy,
|
||||
dto.page,
|
||||
dto.perPage,
|
||||
dto.featured,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
@@ -78,6 +79,22 @@ export class SearchPropertiesDto {
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Chỉ trả về tin đang được đẩy nổi bật (featured)',
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value).toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') return true;
|
||||
if (normalized === 'false' || normalized === '0') return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
featured?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sort order', enum: SortByOption, example: SortByOption.PRICE_ASC })
|
||||
@IsOptional()
|
||||
@IsEnum(SortByOption)
|
||||
|
||||
Reference in New Issue
Block a user