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()