feat(listings): complete featured listings with payment, expiry, and Typesense boost

- Add `featuredPackage` column to Listing (3_days/7_days/30_days)
- Update ActivateFeaturedListingHandler to store package + emit listing.updated for Typesense re-index
- Add ListingFeaturedExpiredHandler in search module to re-index on featured expiry
- Add tier-weighted isFeatured boost in Typesense (30d=3, 7d=2, 3d=1)
- Update expiry cron to clear featuredPackage alongside featuredUntil
- Update admin and promote handlers to persist featuredPackage
- Add/update tests: activation (8 cases), featured-expired search handler

TEC-3070

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 05:09:40 +07:00
parent 606fa0bd4e
commit f7bb0c0dff
13 changed files with 164 additions and 14 deletions

View File

@@ -0,0 +1,42 @@
import { ListingFeaturedExpiredHandler } from '../event-handlers/listing-featured-expired.handler';
describe('ListingFeaturedExpiredHandler', () => {
let handler: ListingFeaturedExpiredHandler;
let mockIndexer: { indexListing: ReturnType<typeof vi.fn> };
let mockCache: {
invalidate: ReturnType<typeof vi.fn>;
invalidateByPrefix: ReturnType<typeof vi.fn>;
};
let mockLogger: { log: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockIndexer = { indexListing: vi.fn().mockResolvedValue(undefined) };
mockCache = {
invalidate: vi.fn().mockResolvedValue(undefined),
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
};
// Provide static buildKey on the mock
(mockCache as any).constructor = { buildKey: (prefix: string, id: string) => `${prefix}:${id}` };
mockLogger = { log: vi.fn() };
handler = new ListingFeaturedExpiredHandler(
mockIndexer as any,
mockCache as any,
mockLogger as any,
);
});
it('re-indexes listing and invalidates caches on featured expiry', async () => {
const event = {
aggregateId: 'listing-1',
expiredAt: new Date(),
eventName: 'listing.featured_expired',
occurredAt: new Date(),
};
await handler.handle(event as any);
expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-1');
expect(mockCache.invalidateByPrefix).toHaveBeenCalled();
});
});

View File

@@ -1,3 +1,4 @@
export { ListingApprovedEventHandler } from './listing-approved.handler';
export { ListingFeaturedExpiredHandler } from './listing-featured-expired.handler';
export { ListingStatusChangedHandler } from './listing-status-changed.handler';
export { SavedSearchAlertHandler } from './saved-search-alert.handler';

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingFeaturedExpiredEvent } from '@modules/listings';
import { CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { ListingIndexerService } from '../services/listing-indexer.service';
@Injectable()
export class ListingFeaturedExpiredHandler {
constructor(
private readonly indexer: ListingIndexerService,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.featured_expired', { async: true })
async handle(event: ListingFeaturedExpiredEvent): Promise<void> {
this.logger.log(
`Handling listing.featured_expired for ${event.aggregateId}`,
'ListingFeaturedExpiredHandler',
);
// Re-index to clear the isFeatured boost in Typesense
await Promise.all([
this.indexer.indexListing(event.aggregateId),
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, event.aggregateId)),
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
this.cache.invalidateByPrefix(CachePrefix.GEO_SEARCH),
]);
}
}

View File

@@ -7,6 +7,16 @@ import {
type ListingDocument,
} from '../../domain/repositories/search.repository';
/** Maps featuredPackage to a tier weight for sort boost: higher = more prominent */
function featuredTierWeight(pkg: string | null | undefined): number {
switch (pkg) {
case '30_days': return 3;
case '7_days': return 2;
case '3_days': return 1;
default: return 1; // fallback for legacy rows with no package
}
}
@Injectable()
export class ListingIndexerService {
constructor(
@@ -110,7 +120,9 @@ export class ListingIndexerService {
saveCount: l.saveCount,
projectName: p.projectName,
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
isFeatured: l.featuredUntil && l.featuredUntil > new Date() ? 1 : 0,
isFeatured: l.featuredUntil && l.featuredUntil > new Date()
? featuredTierWeight(l.featuredPackage as string | null)
: 0,
};
});
}
@@ -159,7 +171,9 @@ export class ListingIndexerService {
saveCount: listing.saveCount,
projectName: p.projectName,
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
isFeatured: listing.featuredUntil && listing.featuredUntil > new Date() ? 1 : 0,
isFeatured: listing.featuredUntil && listing.featuredUntil > new Date()
? featuredTierWeight(listing.featuredPackage as string | null)
: 0,
};
}

View File

@@ -14,6 +14,7 @@ import { SearchPropertiesHandler } from './application/queries/search-properties
import { SEARCH_REPOSITORY } from './domain/repositories/search.repository';
import { SavedSearchAlertCronService } from './infrastructure/cron/saved-search-alert-cron.service';
import { ListingApprovedEventHandler } from './infrastructure/event-handlers/listing-approved.handler';
import { ListingFeaturedExpiredHandler } from './infrastructure/event-handlers/listing-featured-expired.handler';
import { ListingStatusChangedHandler } from './infrastructure/event-handlers/listing-status-changed.handler';
import { SavedSearchAlertHandler } from './infrastructure/event-handlers/saved-search-alert.handler';
import { ListingIndexerService } from './infrastructure/services/listing-indexer.service';
@@ -48,6 +49,7 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearch
// Event handlers
ListingApprovedEventHandler,
ListingFeaturedExpiredHandler,
ListingStatusChangedHandler,
SavedSearchAlertHandler,