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