diff --git a/apps/api/src/modules/listings/application/__tests__/activate-featured-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/activate-featured-listing.handler.spec.ts index 9c96939..3eb4c03 100644 --- a/apps/api/src/modules/listings/application/__tests__/activate-featured-listing.handler.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/activate-featured-listing.handler.spec.ts @@ -7,6 +7,7 @@ describe('ActivateFeaturedListingHandler', () => { listing: { findUnique: ReturnType; update: ReturnType }; }; let mockLogger: { log: ReturnType }; + let mockEventBus: { publish: ReturnType }; 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', diff --git a/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.handler.ts b/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.handler.ts index 19b636b..5ad970f 100644 --- a/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.handler.ts @@ -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: { diff --git a/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.handler.ts b/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.handler.ts index 64bb060..c902c29 100644 --- a/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.handler.ts @@ -82,9 +82,14 @@ export class PromoteFeaturedListingHandler baseDate.getTime() + command.durationDays * 24 * 60 * 60 * 1000, ); + const durationToPackage: Record = { 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( diff --git a/apps/api/src/modules/listings/application/event-handlers/activate-featured-listing.handler.ts b/apps/api/src/modules/listings/application/event-handlers/activate-featured-listing.handler.ts index dc8b600..5559f1f 100644 --- a/apps/api/src/modules/listings/application/event-handlers/activate-featured-listing.handler.ts +++ b/apps/api/src/modules/listings/application/event-handlers/activate-featured-listing.handler.ts @@ -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 = { - '99000': 3, - '199000': 7, - '499000': 30, +const PACKAGE_DURATION_DAYS: Record = { + '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', ); } diff --git a/apps/api/src/modules/listings/index.ts b/apps/api/src/modules/listings/index.ts index 33b5398..954a40e 100644 --- a/apps/api/src/modules/listings/index.ts +++ b/apps/api/src/modules/listings/index.ts @@ -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'; diff --git a/apps/api/src/modules/listings/infrastructure/cron/featured-listing-expiry-cron.service.ts b/apps/api/src/modules/listings/infrastructure/cron/featured-listing-expiry-cron.service.ts index c5f8bce..58d1147 100644 --- a/apps/api/src/modules/listings/infrastructure/cron/featured-listing-expiry-cron.service.ts +++ b/apps/api/src/modules/listings/infrastructure/cron/featured-listing-expiry-cron.service.ts @@ -33,6 +33,7 @@ export class FeaturedListingExpiryCronService { const expired = await this.prisma.$queryRaw>(Prisma.sql` UPDATE "Listing" SET "featuredUntil" = NULL, + "featuredPackage" = NULL, "updatedAt" = NOW() WHERE "featuredUntil" IS NOT NULL AND "featuredUntil" < NOW() diff --git a/apps/api/src/modules/search/infrastructure/__tests__/listing-featured-expired.handler.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/listing-featured-expired.handler.spec.ts new file mode 100644 index 0000000..88e0a00 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/__tests__/listing-featured-expired.handler.spec.ts @@ -0,0 +1,42 @@ +import { ListingFeaturedExpiredHandler } from '../event-handlers/listing-featured-expired.handler'; + +describe('ListingFeaturedExpiredHandler', () => { + let handler: ListingFeaturedExpiredHandler; + let mockIndexer: { indexListing: ReturnType }; + let mockCache: { + invalidate: ReturnType; + invalidateByPrefix: ReturnType; + }; + let mockLogger: { log: ReturnType }; + + 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(); + }); +}); diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/index.ts b/apps/api/src/modules/search/infrastructure/event-handlers/index.ts index 70d9325..aeec732 100644 --- a/apps/api/src/modules/search/infrastructure/event-handlers/index.ts +++ b/apps/api/src/modules/search/infrastructure/event-handlers/index.ts @@ -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'; diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/listing-featured-expired.handler.ts b/apps/api/src/modules/search/infrastructure/event-handlers/listing-featured-expired.handler.ts new file mode 100644 index 0000000..eb1f24c --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/event-handlers/listing-featured-expired.handler.ts @@ -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 { + 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), + ]); + } +} diff --git a/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts b/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts index d0ed9b9..d30336c 100644 --- a/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts +++ b/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts @@ -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, }; } diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts index ae2f584..b98359b 100644 --- a/apps/api/src/modules/search/search.module.ts +++ b/apps/api/src/modules/search/search.module.ts @@ -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, diff --git a/prisma/migrations/20260421000000_add_featured_package/migration.sql b/prisma/migrations/20260421000000_add_featured_package/migration.sql new file mode 100644 index 0000000..d7af0db --- /dev/null +++ b/prisma/migrations/20260421000000_add_featured_package/migration.sql @@ -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. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 32013c0..295f086 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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())