feat(listings): add 3-day listing expiry warning notification (GOO-30)

- Add expiryNotifiedAt column to Listing (migration 20260423100000);
  atomic UPDATE…RETURNING guards against duplicate notifications across
  concurrent cron instances
- Add ListingExpiringEvent domain event (listing.expiring)
- Add ListingExpiryCronService: daily cron at 01:00 UTC; marks
  expiryNotifiedAt before publishing events (idempotent)
- Add ListingExpiringListener: sends EMAIL + Zalo OA via
  SendNotificationCommand with daysRemaining context
- Add listing.expiring Handlebars template (Vietnamese)
- Wire cron into ListingsModule, listener into NotificationsModule
- Update template.service spec: 17 → 19 keys (listing.expiring + the
  pre-existing user.phone_login_otp that was missing from assertion)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 00:16:46 +07:00
parent 4be5eb90a4
commit 94d462ef4f
11 changed files with 334 additions and 5 deletions

View File

@@ -3,5 +3,6 @@ export { ListingApprovedEvent } from './listing-approved.event';
export { ListingPriceChangedEvent } from './listing-price-changed.event';
export { ListingSoldEvent } from './listing-sold.event';
export { ListingFeaturedExpiredEvent } from './listing-featured-expired.event';
export { ListingExpiringEvent } from './listing-expiring.event';
export { ListingOwnershipTransferredEvent } from './listing-ownership-transferred.event';
export { FeaturedListingPaymentRequestedEvent } from './featured-listing-payment-requested.event';

View File

@@ -0,0 +1,19 @@
import { type DomainEvent } from '@modules/shared';
/**
* Fired by the daily expiry-warning cron for each listing that expires
* within the next 3 days and has not yet received a warning notification.
*/
export class ListingExpiringEvent implements DomainEvent {
readonly eventName = 'listing.expiring';
readonly occurredAt = new Date();
constructor(
/** Listing ID */
public readonly aggregateId: string,
/** ID of the seller who owns the listing */
public readonly sellerId: string,
/** When the listing expires */
public readonly expiresAt: Date,
) {}
}

View File

@@ -21,4 +21,5 @@ 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 { ListingExpiringEvent } from './domain/events/listing-expiring.event';
export { Price } from './domain/value-objects/price.vo';

View File

@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ListingStatus } from '@prisma/client';
import { PrismaService, LoggerService } from '@modules/shared';
import { ListingExpiringEvent } from '../../domain/events/listing-expiring.event';
/**
* Daily cron that fires a 3-day expiry warning for active listings.
*
* Design notes:
* - Runs once per day at 08:00 (Vietnam time, UTC+7 → 01:00 UTC).
* - Queries listings whose `expiresAt` falls within the next 13 days
* AND whose `expiryNotifiedAt` is still NULL (idempotent guard).
* - Uses a single atomic SQL UPDATE … RETURNING so concurrent instances
* cannot double-fire: only the first writer will satisfy the NULL predicate.
* - Publishes `ListingExpiringEvent` per affected listing so the
* notifications module can dispatch Zalo OA / email messages.
*/
@Injectable()
export class ListingExpiryCronService {
constructor(
private readonly prisma: PrismaService,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
@Cron('0 1 * * *', { name: 'listing-expiry-warning', timeZone: 'UTC' })
async notifyExpiringListings(): Promise<void> {
const now = new Date();
const in3Days = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
try {
// Atomically claim rows: set expiryNotifiedAt so concurrent instances skip them.
const expiring = await this.prisma.$queryRaw<
Array<{ id: string; sellerId: string; expiresAt: Date }>
>`
UPDATE "Listing"
SET "expiryNotifiedAt" = NOW(),
"updatedAt" = NOW()
WHERE status = ${ListingStatus.ACTIVE}::"ListingStatus"
AND "expiresAt" IS NOT NULL
AND "expiresAt" > ${now}
AND "expiresAt" <= ${in3Days}
AND "expiryNotifiedAt" IS NULL
RETURNING id, "sellerId", "expiresAt"
`;
if (expiring.length === 0) {
this.logger.debug(
'No listings expiring in the next 3 days — nothing to notify',
'ListingExpiryCronService',
);
return;
}
for (const row of expiring) {
this.eventBus.publish(
new ListingExpiringEvent(row.id, row.sellerId, row.expiresAt),
);
}
this.logger.log(
`Sent expiry-warning events for ${expiring.length} listing(s) at ${now.toISOString()}`,
'ListingExpiryCronService',
);
} catch (err) {
this.logger.error(
`Failed to send listing expiry warnings: ${(err as Error).message}`,
(err as Error).stack,
'ListingExpiryCronService',
);
}
}
}

View File

@@ -10,6 +10,7 @@ import { DeleteListingHandler } from './application/commands/delete-listing/dele
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler';
import { ReportListingHandler } from './application/commands/report-listing/report-listing.handler';
import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
@@ -27,6 +28,7 @@ import { DUPLICATE_DETECTOR } from './domain/services/duplicate-detector';
import { ModerationService } from './domain/services/moderation.service';
import { PRICE_VALIDATOR } from './domain/services/price-validator';
import { FeaturedListingExpiryCronService } from './infrastructure/cron/featured-listing-expiry-cron.service';
import { ListingExpiryCronService } from './infrastructure/cron/listing-expiry-cron.service';
import { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository';
import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository';
import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service';
@@ -45,6 +47,7 @@ const CommandHandlers = [
ModerateListingHandler,
DeleteListingHandler,
BulkUpdateListingsHandler,
ReportListingHandler,
];
const QueryHandlers = [
@@ -88,6 +91,7 @@ const EventHandlers = [
// Cron
FeaturedListingExpiryCronService,
ListingExpiryCronService,
// Guards (per-route)
FeatureListingThrottlerGuard,

View File

@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { LoggerService, PrismaService } from '@modules/shared';
import { ListingExpiringEvent } from '@modules/listings';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
/**
* Handles `listing.expiring` events published by the daily expiry-warning cron.
*
* Sends both an email and a Zalo OA notification to the seller so they can
* renew or extend their listing before it expires.
*/
@Injectable()
export class ListingExpiringListener {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.expiring', { async: true })
async handle(event: ListingExpiringEvent): Promise<void> {
this.logger.log(
`Handling listing.expiring for listing ${event.aggregateId}`,
'ListingExpiringListener',
);
const listing = await this.prisma.listing.findUnique({
where: { id: event.aggregateId },
include: {
property: { select: { title: true } },
seller: { select: { id: true, email: true, phone: true } },
},
});
if (!listing) return;
const templateData = {
listingTitle: listing.property.title,
expiresAt: event.expiresAt.toISOString(),
daysRemaining: Math.ceil(
(event.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
),
};
const notifications: Promise<unknown>[] = [];
// Email notification
if (listing.seller.email) {
notifications.push(
this.commandBus.execute(
new SendNotificationCommand(
listing.seller.id,
'EMAIL',
'listing.expiring',
templateData,
listing.seller.email,
),
),
);
}
// Zalo OA notification (phone as recipient address)
if (listing.seller.phone) {
notifications.push(
this.commandBus.execute(
new SendNotificationCommand(
listing.seller.id,
'ZALO_OA',
'listing.expiring',
templateData,
listing.seller.phone,
),
),
);
}
await Promise.allSettled(notifications);
}
}

View File

@@ -81,14 +81,15 @@ describe('TemplateService', () => {
expect(result.body).toContain('/listings/2');
});
it('getTemplateKeys returns all 17 template keys', () => {
it('getTemplateKeys returns all 19 template keys', () => {
const keys = service.getTemplateKeys();
expect(keys).toHaveLength(17);
expect(keys).toHaveLength(19);
expect(keys).toContain('user.registered');
expect(keys).toContain('agent.verified');
expect(keys).toContain('listing.approved');
expect(keys).toContain('listing.rejected');
expect(keys).toContain('listing.expiring');
expect(keys).toContain('inquiry.received');
expect(keys).toContain('quota.exceeded');
expect(keys).toContain('password.reset');
@@ -98,6 +99,7 @@ describe('TemplateService', () => {
expect(keys).toContain('saved_search_digest');
expect(keys).toContain('user.email_change_otp');
expect(keys).toContain('user.phone_change_otp');
expect(keys).toContain('user.phone_login_otp');
expect(keys).toContain('inquiry.reply');
expect(keys).toContain('listing.price_drop');
expect(keys).toContain('subscription.renewal');

View File

@@ -30,6 +30,13 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
subject: 'Tin đăng đã được duyệt',
body: `<h1>Tin đăng được phê duyệt!</h1>
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn đã được duyệt và hiển thị trên GoodGo.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'listing.expiring': {
subject: 'Tin đăng sắp hết hạn: {{listingTitle}}',
body: `<h1>Tin đăng sắp hết hạn</h1>
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn sẽ hết hạn trong <strong>{{daysRemaining}} ngày</strong> ({{expiresAt}}).</p>
<p>Vui lòng gia hạn tin đăng để tiếp tục hiển thị trên GoodGo.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'inquiry.received': {
@@ -90,6 +97,10 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
subject: 'Xác nhận thay đổi số điện thoại — GoodGo',
body: `Mã xác nhận thay đổi số điện thoại GoodGo: {{otpCode}}. Mã có hiệu lực trong 10 phút. Nếu bạn không yêu cầu, hãy bỏ qua tin nhắn này.`,
},
'user.phone_login_otp': {
subject: 'Mã đăng nhập GoodGo',
body: `Mã đăng nhập GoodGo: {{otpCode}}. Mã có hiệu lực trong 10 phút. Tuyệt đối không chia sẻ mã này với bất kỳ ai.`,
},
'saved_search_alert': {
subject: 'Tin mới phù hợp tìm kiếm "{{searchName}}"',
body: `<h1>Xin chào {{userName}}!</h1>

View File

@@ -7,6 +7,7 @@ import { AgentVerifiedListener } from './application/listeners/agent-verified.li
import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener';
import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener';
import { ListingApprovedListener } from './application/listeners/listing-approved.listener';
import { ListingExpiringListener } from './application/listeners/listing-expiring.listener';
import { ListingRejectedListener } from './application/listeners/listing-rejected.listener';
import { ListingSoldListener } from './application/listeners/listing-sold.listener';
import { PasswordResetRequestedListener } from './application/listeners/password-reset-requested.listener';
@@ -14,6 +15,7 @@ import { PaymentCompletedListener } from './application/listeners/payment-comple
import { PaymentFailedListener } from './application/listeners/payment-failed.listener';
import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener';
import { PhoneChangeRequestedListener } from './application/listeners/phone-change-requested.listener';
import { PhoneLoginOtpRequestedListener } from './application/listeners/phone-login-otp-requested.listener';
import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
import {
ResidentialInquiryReplyListener,
@@ -48,6 +50,7 @@ const EventListeners = [
AgentVerifiedListener,
QuotaExceededListener,
ListingApprovedListener,
ListingExpiringListener,
ListingRejectedListener,
PaymentCompletedListener,
PaymentFailedListener,
@@ -60,6 +63,7 @@ const EventListeners = [
UserKycUpdatedListener,
EmailChangeRequestedListener,
PhoneChangeRequestedListener,
PhoneLoginOtpRequestedListener,
PasswordResetRequestedListener,
ResidentialPriceDropListener,
ResidentialNewListingInProjectListener,