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> }; listing: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
}; };
let mockLogger: { log: ReturnType<typeof vi.fn> }; let mockLogger: { log: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => { beforeEach(() => {
mockPrisma = { mockPrisma = {
@@ -14,10 +15,12 @@ describe('ActivateFeaturedListingHandler', () => {
listing: { findUnique: vi.fn(), update: vi.fn() }, listing: { findUnique: vi.fn(), update: vi.fn() },
}; };
mockLogger = { log: vi.fn() }; mockLogger = { log: vi.fn() };
mockEventBus = { publish: vi.fn() };
handler = new ActivateFeaturedListingHandler( handler = new ActivateFeaturedListingHandler(
mockPrisma as any, mockPrisma as any,
mockLogger as any, mockLogger as any,
mockEventBus as any,
); );
}); });
@@ -34,7 +37,7 @@ describe('ActivateFeaturedListingHandler', () => {
expect(mockPrisma.listing.update).toHaveBeenCalledWith({ expect(mockPrisma.listing.update).toHaveBeenCalledWith({
where: { id: 'listing-1' }, 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]; const updateCall = mockPrisma.listing.update.mock.calls[0][0];
@@ -58,6 +61,25 @@ describe('ActivateFeaturedListingHandler', () => {
const featuredUntil = updateCall.data.featuredUntil as Date; const featuredUntil = updateCall.data.featuredUntil as Date;
const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
expect(diffDays).toBe(3); 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 () => { it('extends from existing featuredUntil if still in the future', async () => {
@@ -79,6 +101,25 @@ describe('ActivateFeaturedListingHandler', () => {
expect(diffDays).toBe(12); 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 () => { it('ignores non-FEATURED_LISTING payments', async () => {
mockPrisma.payment.findUnique.mockResolvedValue({ mockPrisma.payment.findUnique.mockResolvedValue({
type: 'SUBSCRIPTION', type: 'SUBSCRIPTION',

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { type PaymentCompletedEvent } from '@modules/payments'; 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> = { const PACKAGE_DURATION_DAYS: Record<string, { days: number; package_: string }> = {
'99000': 3, '99000': { days: 3, package_: '3_days' },
'199000': 7, '199000': { days: 7, package_: '7_days' },
'499000': 30, '499000': { days: 30, package_: '30_days' },
}; };
@Injectable() @Injectable()
@@ -14,6 +14,7 @@ export class ActivateFeaturedListingHandler {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly logger: LoggerService, private readonly logger: LoggerService,
private readonly eventBus: EventBusService,
) {} ) {}
@OnEvent('payment.completed', { async: true }) @OnEvent('payment.completed', { async: true })
@@ -28,7 +29,7 @@ export class ActivateFeaturedListingHandler {
} }
const listingId = payment.transactionId; 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 now = new Date();
const listing = await this.prisma.listing.findUnique({ const listing = await this.prisma.listing.findUnique({
@@ -41,15 +42,18 @@ export class ActivateFeaturedListingHandler {
? listing.featuredUntil ? listing.featuredUntil
: now; : 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({ await this.prisma.listing.update({
where: { id: listingId }, 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( 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', 'ActivateFeaturedListingHandler',
); );
} }

View File

@@ -20,4 +20,5 @@ export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.
export { ListingSoldEvent } from './domain/events/listing-sold.event'; export { ListingSoldEvent } from './domain/events/listing-sold.event';
export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event'; export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event';
export { ListingOwnershipTransferredEvent } from './domain/events/listing-ownership-transferred.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'; 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` const expired = await this.prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
UPDATE "Listing" UPDATE "Listing"
SET "featuredUntil" = NULL, SET "featuredUntil" = NULL,
"featuredPackage" = NULL,
"updatedAt" = NOW() "updatedAt" = NOW()
WHERE "featuredUntil" IS NOT NULL WHERE "featuredUntil" IS NOT NULL
AND "featuredUntil" < NOW() 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 { ListingApprovedEventHandler } from './listing-approved.handler';
export { ListingFeaturedExpiredHandler } from './listing-featured-expired.handler';
export { ListingStatusChangedHandler } from './listing-status-changed.handler'; export { ListingStatusChangedHandler } from './listing-status-changed.handler';
export { SavedSearchAlertHandler } from './saved-search-alert.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, type ListingDocument,
} from '../../domain/repositories/search.repository'; } 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() @Injectable()
export class ListingIndexerService { export class ListingIndexerService {
constructor( constructor(
@@ -110,7 +120,9 @@ export class ListingIndexerService {
saveCount: l.saveCount, saveCount: l.saveCount,
projectName: p.projectName, projectName: p.projectName,
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [], 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, saveCount: listing.saveCount,
projectName: p.projectName, projectName: p.projectName,
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [], 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 { SEARCH_REPOSITORY } from './domain/repositories/search.repository';
import { SavedSearchAlertCronService } from './infrastructure/cron/saved-search-alert-cron.service'; import { SavedSearchAlertCronService } from './infrastructure/cron/saved-search-alert-cron.service';
import { ListingApprovedEventHandler } from './infrastructure/event-handlers/listing-approved.handler'; 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 { ListingStatusChangedHandler } from './infrastructure/event-handlers/listing-status-changed.handler';
import { SavedSearchAlertHandler } from './infrastructure/event-handlers/saved-search-alert.handler'; import { SavedSearchAlertHandler } from './infrastructure/event-handlers/saved-search-alert.handler';
import { ListingIndexerService } from './infrastructure/services/listing-indexer.service'; import { ListingIndexerService } from './infrastructure/services/listing-indexer.service';
@@ -48,6 +49,7 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearch
// Event handlers // Event handlers
ListingApprovedEventHandler, ListingApprovedEventHandler,
ListingFeaturedExpiredHandler,
ListingStatusChangedHandler, ListingStatusChangedHandler,
SavedSearchAlertHandler, 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) saveCount Int @default(0)
inquiryCount Int @default(0) inquiryCount Int @default(0)
featuredUntil DateTime? featuredUntil DateTime?
featuredPackage String? /// "3_days" | "7_days" | "30_days"
expiresAt DateTime? expiresAt DateTime?
publishedAt DateTime? publishedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())