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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user