feat(notifications): add Zalo OA R8.2 ZNS templates (TEC-2765)
Adds the four R8.2 template channels missed in prior heartbeats: - inquiry.reply (env: ZALO_ZNS_TEMPLATE_INQUIRY_REPLY) - listing.price_drop (env: ZALO_ZNS_TEMPLATE_PRICE_DROP) - subscription.renewal (env: ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL) - subscription.renewed (env: ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWED) template.service.ts gets matching email/in-app bodies so the keys render across channels (not just ZNS). Spec key count bumped 13 to 17 and zalo-zns-templates.spec.ts validates env gating + param mapping. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -81,10 +81,10 @@ describe('TemplateService', () => {
|
||||
expect(result.body).toContain('/listings/2');
|
||||
});
|
||||
|
||||
it('getTemplateKeys returns all 13 template keys', () => {
|
||||
it('getTemplateKeys returns all 17 template keys', () => {
|
||||
const keys = service.getTemplateKeys();
|
||||
|
||||
expect(keys).toHaveLength(13);
|
||||
expect(keys).toHaveLength(17);
|
||||
expect(keys).toContain('user.registered');
|
||||
expect(keys).toContain('agent.verified');
|
||||
expect(keys).toContain('listing.approved');
|
||||
@@ -98,5 +98,9 @@ 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('inquiry.reply');
|
||||
expect(keys).toContain('listing.price_drop');
|
||||
expect(keys).toContain('subscription.renewal');
|
||||
expect(keys).toContain('subscription.renewed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getZaloZnsTemplates } from '../services/zalo-zns-templates';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ZALO_ZNS_TEMPLATE_INQUIRY',
|
||||
'ZALO_ZNS_TEMPLATE_INQUIRY_REPLY',
|
||||
'ZALO_ZNS_TEMPLATE_PAYMENT',
|
||||
'ZALO_ZNS_TEMPLATE_LISTING_APPROVED',
|
||||
'ZALO_ZNS_TEMPLATE_LISTING_REJECTED',
|
||||
'ZALO_ZNS_TEMPLATE_LISTING_SOLD',
|
||||
'ZALO_ZNS_TEMPLATE_PRICE_DROP',
|
||||
'ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL',
|
||||
];
|
||||
|
||||
describe('getZaloZnsTemplates', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty map when no template env vars are configured', () => {
|
||||
expect(getZaloZnsTemplates()).toEqual({});
|
||||
});
|
||||
|
||||
it('registers R8.2 templates when their env vars are present', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_INQUIRY_REPLY'] = 'tpl-inquiry-reply';
|
||||
process.env['ZALO_ZNS_TEMPLATE_PRICE_DROP'] = 'tpl-price-drop';
|
||||
process.env['ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL'] = 'tpl-sub-renewal';
|
||||
|
||||
const tpls = getZaloZnsTemplates();
|
||||
|
||||
expect(tpls['inquiry.reply']?.templateId).toBe('tpl-inquiry-reply');
|
||||
expect(tpls['listing.price_drop']?.templateId).toBe('tpl-price-drop');
|
||||
expect(tpls['subscription.renewal']?.templateId).toBe('tpl-sub-renewal');
|
||||
});
|
||||
|
||||
it('maps inquiry.reply params correctly', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_INQUIRY_REPLY'] = 'tpl-1';
|
||||
const tpl = getZaloZnsTemplates()['inquiry.reply']!;
|
||||
|
||||
expect(
|
||||
tpl.mapParams({
|
||||
agentName: 'Nguyễn Văn A',
|
||||
listingTitle: 'Căn hộ Q.1',
|
||||
message: 'Chào bạn',
|
||||
}),
|
||||
).toEqual({
|
||||
agent_name: 'Nguyễn Văn A',
|
||||
property_name: 'Căn hộ Q.1',
|
||||
message: 'Chào bạn',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps listing.price_drop params correctly', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_PRICE_DROP'] = 'tpl-2';
|
||||
const tpl = getZaloZnsTemplates()['listing.price_drop']!;
|
||||
|
||||
expect(
|
||||
tpl.mapParams({
|
||||
listingTitle: 'Nhà phố Q.7',
|
||||
oldPriceVND: 5_000_000_000,
|
||||
newPriceVND: 4_500_000_000,
|
||||
}),
|
||||
).toEqual({
|
||||
listing_title: 'Nhà phố Q.7',
|
||||
old_price: '5000000000',
|
||||
new_price: '4500000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps subscription.renewal params correctly', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL'] = 'tpl-3';
|
||||
const tpl = getZaloZnsTemplates()['subscription.renewal']!;
|
||||
|
||||
expect(
|
||||
tpl.mapParams({
|
||||
planTier: 'PRO',
|
||||
renewalDate: '2026-05-01',
|
||||
amountVND: 299000,
|
||||
}),
|
||||
).toEqual({
|
||||
plan_tier: 'PRO',
|
||||
renewal_date: '2026-05-01',
|
||||
amount: '299000',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to empty strings for missing data keys', () => {
|
||||
process.env['ZALO_ZNS_TEMPLATE_PRICE_DROP'] = 'tpl-2';
|
||||
const tpl = getZaloZnsTemplates()['listing.price_drop']!;
|
||||
|
||||
expect(tpl.mapParams({})).toEqual({
|
||||
listing_title: '',
|
||||
old_price: '',
|
||||
new_price: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,34 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
</table>
|
||||
<p><a href="{{listingUrl}}" style="display:inline-block;padding:10px 20px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;">Xem chi tiết</a></p>
|
||||
<p style="color:#6b7280;font-size:14px;">Bạn có thể tắt thông báo cho tìm kiếm này trong phần <a href="/saved-searches">Tìm kiếm đã lưu</a>.</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'inquiry.reply': {
|
||||
subject: 'Phản hồi mới cho yêu cầu tư vấn của bạn',
|
||||
body: `<h1>Phản hồi mới từ {{agentName}}</h1>
|
||||
<p>Bạn nhận được phản hồi cho yêu cầu tư vấn về tin đăng <strong>{{listingTitle}}</strong>.</p>
|
||||
<p>Nội dung: {{message}}</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'listing.price_drop': {
|
||||
subject: 'Tin đăng đã giảm giá: {{listingTitle}}',
|
||||
body: `<h1>Giá đã giảm!</h1>
|
||||
<p>Tin đăng <strong>{{listingTitle}}</strong> mà bạn đang theo dõi vừa giảm giá.</p>
|
||||
<p>Giá cũ: <strong>{{oldPriceVND}} VNĐ</strong></p>
|
||||
<p>Giá mới: <strong>{{newPriceVND}} VNĐ</strong></p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'subscription.renewal': {
|
||||
subject: 'Nhắc nhở gia hạn gói {{planTier}}',
|
||||
body: `<h1>Gói đăng ký sắp gia hạn</h1>
|
||||
<p>Gói <strong>{{planTier}}</strong> của bạn sẽ được gia hạn vào <strong>{{renewalDate}}</strong>.</p>
|
||||
<p>Số tiền dự kiến: <strong>{{amountVND}} VNĐ</strong></p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'subscription.renewed': {
|
||||
subject: 'Gói {{planTier}} đã được gia hạn',
|
||||
body: `<h1>Gia hạn thành công</h1>
|
||||
<p>Gói <strong>{{planTier}}</strong> của bạn đã được gia hạn đến <strong>{{periodEnd}}</strong>.</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'saved_search_digest': {
|
||||
|
||||
@@ -83,5 +83,56 @@ export function getZaloZnsTemplates(): Record<string, ZaloZnsTemplateConfig> {
|
||||
};
|
||||
}
|
||||
|
||||
// R8.2 — Inquiry reply: notify the original inquirer that the agent/owner replied
|
||||
const inquiryReplyTplId = process.env['ZALO_ZNS_TEMPLATE_INQUIRY_REPLY'] ?? '';
|
||||
if (inquiryReplyTplId) {
|
||||
templates['inquiry.reply'] = {
|
||||
templateId: inquiryReplyTplId,
|
||||
mapParams: (data) => ({
|
||||
agent_name: String(data['agentName'] ?? ''),
|
||||
property_name: String(data['listingTitle'] ?? ''),
|
||||
message: String(data['message'] ?? ''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// R8.2 — Listing price drop: notify watchers/saved-search subscribers
|
||||
const priceDropTplId = process.env['ZALO_ZNS_TEMPLATE_PRICE_DROP'] ?? '';
|
||||
if (priceDropTplId) {
|
||||
templates['listing.price_drop'] = {
|
||||
templateId: priceDropTplId,
|
||||
mapParams: (data) => ({
|
||||
listing_title: String(data['listingTitle'] ?? ''),
|
||||
old_price: String(data['oldPriceVND'] ?? ''),
|
||||
new_price: String(data['newPriceVND'] ?? ''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// R8.2 — Subscription renewal reminder (pre-renewal heads-up)
|
||||
const subscriptionRenewalTplId = process.env['ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWAL'] ?? '';
|
||||
if (subscriptionRenewalTplId) {
|
||||
templates['subscription.renewal'] = {
|
||||
templateId: subscriptionRenewalTplId,
|
||||
mapParams: (data) => ({
|
||||
plan_tier: String(data['planTier'] ?? ''),
|
||||
renewal_date: String(data['renewalDate'] ?? ''),
|
||||
amount: String(data['amountVND'] ?? ''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// R8.2 — Subscription renewed (post-renewal confirmation; matches SubscriptionRenewedListener)
|
||||
const subscriptionRenewedTplId = process.env['ZALO_ZNS_TEMPLATE_SUBSCRIPTION_RENEWED'] ?? '';
|
||||
if (subscriptionRenewedTplId) {
|
||||
templates['subscription.renewed'] = {
|
||||
templateId: subscriptionRenewedTplId,
|
||||
mapParams: (data) => ({
|
||||
plan_tier: String(data['planTier'] ?? ''),
|
||||
period_end: String(data['periodEnd'] ?? ''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user