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

@@ -7,6 +7,7 @@ describe('ActivateFeaturedListingHandler', () => {
listing: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
@@ -14,10 +15,12 @@ describe('ActivateFeaturedListingHandler', () => {
listing: { findUnique: vi.fn(), update: vi.fn() },
};
mockLogger = { log: vi.fn() };
mockEventBus = { publish: vi.fn() };
handler = new ActivateFeaturedListingHandler(
mockPrisma as any,
mockLogger as any,
mockEventBus as any,
);
});
@@ -34,7 +37,7 @@ describe('ActivateFeaturedListingHandler', () => {
expect(mockPrisma.listing.update).toHaveBeenCalledWith({
where: { id: 'listing-1' },
data: { featuredUntil: expect.any(Date) },
data: { featuredUntil: expect.any(Date), featuredPackage: '7_days' },
});
const updateCall = mockPrisma.listing.update.mock.calls[0][0];
@@ -58,6 +61,25 @@ describe('ActivateFeaturedListingHandler', () => {
const featuredUntil = updateCall.data.featuredUntil as Date;
const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
expect(diffDays).toBe(3);
expect(updateCall.data.featuredPackage).toBe('3_days');
});
it('activates featured listing for 30 days on 499000 VND payment', async () => {
mockPrisma.payment.findUnique.mockResolvedValue({
type: 'FEATURED_LISTING',
transactionId: 'listing-1',
amountVND: 499000n,
});
mockPrisma.listing.findUnique.mockResolvedValue({ featuredUntil: null });
mockPrisma.listing.update.mockResolvedValue({});
await handler.handle({ aggregateId: 'pay-1' } as any);
const updateCall = mockPrisma.listing.update.mock.calls[0][0];
const featuredUntil = updateCall.data.featuredUntil as Date;
const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
expect(diffDays).toBe(30);
expect(updateCall.data.featuredPackage).toBe('30_days');
});
it('extends from existing featuredUntil if still in the future', async () => {
@@ -79,6 +101,25 @@ describe('ActivateFeaturedListingHandler', () => {
expect(diffDays).toBe(12);
});
it('publishes listing.updated event for Typesense re-indexing', async () => {
mockPrisma.payment.findUnique.mockResolvedValue({
type: 'FEATURED_LISTING',
transactionId: 'listing-1',
amountVND: 199000n,
});
mockPrisma.listing.findUnique.mockResolvedValue({ featuredUntil: null });
mockPrisma.listing.update.mockResolvedValue({});
await handler.handle({ aggregateId: 'pay-1' } as any);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'listing.updated',
aggregateId: 'listing-1',
}),
);
});
it('ignores non-FEATURED_LISTING payments', async () => {
mockPrisma.payment.findUnique.mockResolvedValue({
type: 'SUBSCRIPTION',

View File

@@ -58,7 +58,10 @@ export class AdminFeatureListingHandler
await this.prisma.$transaction([
this.prisma.listing.update({
where: { id: command.listingId },
data: { featuredUntil },
data: {
featuredUntil,
featuredPackage: command.action === 'feature' ? `${command.durationDays}_days` : null,
},
}),
this.prisma.adminAuditLog.create({
data: {

View File

@@ -82,9 +82,14 @@ export class PromoteFeaturedListingHandler
baseDate.getTime() + command.durationDays * 24 * 60 * 60 * 1000,
);
const durationToPackage: Record<number, string> = { 3: '3_days', 7: '7_days', 30: '30_days' };
await this.prisma.listing.update({
where: { id: command.listingId },
data: { featuredUntil },
data: {
featuredUntil,
featuredPackage: durationToPackage[command.durationDays] ?? `${command.durationDays}_days`,
},
});
await this.commandBus.execute(

View File

@@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { type PaymentCompletedEvent } from '@modules/payments';
import { PrismaService, LoggerService } from '@modules/shared';
import { PrismaService, LoggerService, EventBusService } from '@modules/shared';
const PACKAGE_DURATION_DAYS: Record<string, number> = {
'99000': 3,
'199000': 7,
'499000': 30,
const PACKAGE_DURATION_DAYS: Record<string, { days: number; package_: string }> = {
'99000': { days: 3, package_: '3_days' },
'199000': { days: 7, package_: '7_days' },
'499000': { days: 30, package_: '30_days' },
};
@Injectable()
@@ -14,6 +14,7 @@ export class ActivateFeaturedListingHandler {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
private readonly eventBus: EventBusService,
) {}
@OnEvent('payment.completed', { async: true })
@@ -28,7 +29,7 @@ export class ActivateFeaturedListingHandler {
}
const listingId = payment.transactionId;
const days = PACKAGE_DURATION_DAYS[payment.amountVND.toString()] ?? 7;
const pkg = PACKAGE_DURATION_DAYS[payment.amountVND.toString()] ?? { days: 7, package_: '7_days' };
const now = new Date();
const listing = await this.prisma.listing.findUnique({
@@ -41,15 +42,18 @@ export class ActivateFeaturedListingHandler {
? listing.featuredUntil
: now;
const featuredUntil = new Date(baseDate.getTime() + days * 24 * 60 * 60 * 1000);
const featuredUntil = new Date(baseDate.getTime() + pkg.days * 24 * 60 * 60 * 1000);
await this.prisma.listing.update({
where: { id: listingId },
data: { featuredUntil },
data: { featuredUntil, featuredPackage: pkg.package_ },
});
// Trigger Typesense re-index so the listing gets featured boost in search
this.eventBus.publish({ eventName: 'listing.updated', aggregateId: listingId, occurredAt: new Date() });
this.logger.log(
`Activated featured listing: id=${listingId}, until=${featuredUntil.toISOString()}, days=${days}`,
`Activated featured listing: id=${listingId}, package=${pkg.package_}, until=${featuredUntil.toISOString()}, days=${pkg.days}`,
'ActivateFeaturedListingHandler',
);
}

View File

@@ -20,4 +20,5 @@ export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.
export { ListingSoldEvent } from './domain/events/listing-sold.event';
export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event';
export { ListingOwnershipTransferredEvent } from './domain/events/listing-ownership-transferred.event';
export { ListingFeaturedExpiredEvent } from './domain/events/listing-featured-expired.event';
export { Price } from './domain/value-objects/price.vo';

View File

@@ -33,6 +33,7 @@ export class FeaturedListingExpiryCronService {
const expired = await this.prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
UPDATE "Listing"
SET "featuredUntil" = NULL,
"featuredPackage" = NULL,
"updatedAt" = NOW()
WHERE "featuredUntil" IS NOT NULL
AND "featuredUntil" < NOW()

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,

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Listing" ADD COLUMN "featuredPackage" TEXT;
-- Backfill existing featured listings based on featuredUntil duration (best-effort)
-- No backfill needed: featuredPackage is informational for new purchases only.

View File

@@ -407,6 +407,7 @@ model Listing {
saveCount Int @default(0)
inquiryCount Int @default(0)
featuredUntil DateTime?
featuredPackage String? /// "3_days" | "7_days" | "30_days"
expiresAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())