From 5d8a4eec7920bec3785e3b7fe34c9cd932f987ae Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 18 Apr 2026 15:01:46 +0700 Subject: [PATCH] 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 --- .../__tests__/template.service.spec.ts | 8 +- .../__tests__/zalo-zns-templates.spec.ts | 109 ++++++++++++++++++ .../services/template.service.ts | 28 +++++ .../services/zalo-zns-templates.ts | 51 ++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/modules/notifications/infrastructure/__tests__/zalo-zns-templates.spec.ts diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts index a23b9bc..e422222 100644 --- a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts @@ -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'); }); }); diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/zalo-zns-templates.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/zalo-zns-templates.spec.ts new file mode 100644 index 0000000..365b5a1 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/zalo-zns-templates.spec.ts @@ -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 = {}; + + 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: '', + }); + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts index 4566864..60d884c 100644 --- a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts +++ b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts @@ -107,6 +107,34 @@ const TEMPLATES: Record = {

Xem chi tiết

Bạn có thể tắt thông báo cho tìm kiếm này trong phần Tìm kiếm đã lưu.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'inquiry.reply': { + subject: 'Phản hồi mới cho yêu cầu tư vấn của bạn', + body: `

Phản hồi mới từ {{agentName}}

+

Bạn nhận được phản hồi cho yêu cầu tư vấn về tin đăng {{listingTitle}}.

+

Nội dung: {{message}}

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'listing.price_drop': { + subject: 'Tin đăng đã giảm giá: {{listingTitle}}', + body: `

Giá đã giảm!

+

Tin đăng {{listingTitle}} mà bạn đang theo dõi vừa giảm giá.

+

Giá cũ: {{oldPriceVND}} VNĐ

+

Giá mới: {{newPriceVND}} VNĐ

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'subscription.renewal': { + subject: 'Nhắc nhở gia hạn gói {{planTier}}', + body: `

Gói đăng ký sắp gia hạn

+

Gói {{planTier}} của bạn sẽ được gia hạn vào {{renewalDate}}.

+

Số tiền dự kiến: {{amountVND}} VNĐ

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'subscription.renewed': { + subject: 'Gói {{planTier}} đã được gia hạn', + body: `

Gia hạn thành công

+

Gói {{planTier}} của bạn đã được gia hạn đến {{periodEnd}}.

Trân trọng,
Đội ngũ GoodGo

`, }, 'saved_search_digest': { diff --git a/apps/api/src/modules/notifications/infrastructure/services/zalo-zns-templates.ts b/apps/api/src/modules/notifications/infrastructure/services/zalo-zns-templates.ts index 26d6bb1..d7165b8 100644 --- a/apps/api/src/modules/notifications/infrastructure/services/zalo-zns-templates.ts +++ b/apps/api/src/modules/notifications/infrastructure/services/zalo-zns-templates.ts @@ -83,5 +83,56 @@ export function getZaloZnsTemplates(): Record { }; } + // 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; }