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