Technical handoff · for reviewers · JOY-260513-gA2AP6

Multi-language Notification Email by Customer Locale

Email thông báo loyalty render theo customer.locale: lazy-fetch locale từ Shopify GraphQL (REST/webhook không có field này), lưu nội dung per-locale trên notification doc, auto-dịch khi merchant thêm ngôn ngữ.

feature/email-multi-language-customer-locale 1 commit · 9 files · +308 / −21 0 new lint errors

1Tóm tắt

Shopify Customer.locale (BCP47, vd fr-CA) chỉ có ở GraphQL Admin API — REST customer object và customers/update webhook payload đều không có (đã verify thực nghiệm). Vì vậy không thể lấy locale từ luồng sync/webhook hiện tại. Hướng chọn: lazy-fetch một lần / khách ngay trước khi gửi email (GraphQL + Redis lock + sentinel), rồi resolve nội dung email theo locale từ map translations[locale] lưu sẵn trên notification doc. Bulk-sync cũng được bổ sung field locale để khách đồng bộ về sau có sẵn, giảm số lần lazy-fetch.

2Architecture & data flow

Luồng A — gửi email theo locale của khách (subscribeSentEmailNotification.getEmailData):

Trigger email
point earned / event → triggerSendEmail
ensureCustomerLocale
lazy-fetch nếu khách chưa có locale
resolveEmailContentByLocale
exact → language-only → regional sibling
Render + send
override 6 field + point label theo locale

Luồng B — lazy-fetch locale (customerLocaleService.ensureCustomerLocale):

Guard
skip nếu đã có locale hoặc đã có sentinel localeFetchedAt
Redis setNX lock
locale-fetch:{shop}:{cust}, TTL 30s — chống fetch trùng
GraphQL
customer(id){ locale }
Persist
updateCustomerById({locale, localeFetchedAt})

Luồng C — auto-dịch khi thêm ngôn ngữ (translationController.createAdditionLanguage → fire-and-forget):

Add language
merchant thêm ngôn ngữ ở admin
autoTranslateEmailNotifications
Redis rate-limit 1 run / shop+lang / 10'
Gemini translateTexts
batch 6 field / notification
Save dot-path
translations.{lang} — merge, không clobber

Quyết định thiết kế

Quyết địnhChọnVì sao (vs phương án khác)
Nguồn localeLazy-fetch GraphQL, 1 lần/kháchREST + webhook KHÔNG có Customer.locale (verify thực nghiệm). Fetch eager cho toàn bộ khách tốn quota + chậm; lazy-fetch chỉ chạm khách thực sự nhận email, có sentinel để fetch-once.
Chống fetch trùngRedis setNX lock + sentinel localeFetchedAt2 email cùng lúc cho 1 khách chưa có locale → chỉ 1 GraphQL call; sentinel tránh re-fetch khách thật sự không set locale (locale rỗng).
Lưu nội dung per-localetranslations[locale] map trên notification doc, update dot-pathDot-path update({'translations.fr': x}) merge nested, không ghi đè sibling (vi/de). Đã verify thêm de không mất vi/fr.
Fallback resolutionexact → language-only → regional sibling → primaryfr-CA match fr; locale lạ (ckb-US) rơi về primary language của shop thay vì gửi rỗng.
Point label ngôn ngữgetTranslationForShop(shopId, pointLabelLocale), gate bằng pointLabelLocaleTránh phân kỳ: chỉ đổi label điểm khi thực sự render bản localized; mail test / html-mode giữ primary.

3Files changed

FileThay đổi±
services/customer/customerLocaleService.jsNEWensureCustomerLocale (lazy-fetch + lock) + resolveEmailContentByLocale (fallback chain)NEW · 64
services/email/emailNotificationTranslationService.jsNEWautoTranslateEmailNotifications (Gemini batch + dot-path save + rate-limit)NEW · 74
handlers/pubsub/subscribeSentEmailNotification.jsgetEmailData: resolve contentLocale, override 6 field + point label; wire lazy-fetch ở triggerSendEmail+46
pages/Notifications/Edit.jsLanguage dropdown, localizedData overlay, handleChangeLocalized routing, preview-language ở send-test+108
pages/Notifications/Details/EmailSettingsV2.jsLanguage Select card + remount RichTextEditor theo contentLocaleKey+19
services/optimize/bulkOperationService.jsThêm locale vào CUSTOMERS_QUERY + CUSTOMERS_QUERY_B2B+4
const/customers.jsCUSTOMER_LOCALE + thêm locale vào shopifyCustomerUpdateFields+4
controllers/translationController.jsFire-and-forget autoTranslateEmailNotifications sau khi add language+7
services/customer/syncCustomerService.jstransformCustomer: map locale: customer.locale || null+3

4Code-review findings & fixes

Max-effort review (5 angle × verify × sweep): 15 finding, đã fix 7 cái load-bearing; còn lại là edge bậc thấp đã ghi follow-up.

#SevFindingFixVerified
1HIGHReset button ghi đè nội dung primary khi đang xem ngôn ngữ phụReset đi qua handleChangeLocalized (route đúng translations[locale])
7HIGHSave translations clobber cả map translations → mất ngôn ngữ khácUpdate dot-path translations.{lang}✓ thêm de, giữ vi/fr
5HIGHGemini trả {} → persist rỗng, mask lỗi + giữ lock 10'Guard if (!Object.keys(translated).length) continue
2/3MEDPoint label phân kỳ ngôn ngữ với nội dung emailGate qua pointLabelLocale, dùng getTranslationForShop
4MEDSend-test "real customer" bỏ qua customer.localeOmit previewLocale ở nhánh real-customer
6MEDHTML-mode trộn ngôn ngữ (override field trên custom HTML)Skip override khi emailMode === 'html'
10LOWDropdown hiện cả ngôn ngữ đã tắtFilter active !== false

5Edge cases & test plan

CaseExpectedCách test (QA)
Locale có vùng (fr-CA), chỉ có bản frKhớp sibling → render bản frSet customer locale fr-CA, có translations[fr], gửi → subject tiếng Pháp
Locale lạ (ckb-US), không có bản nàoFallback về primary languageĐã verify dev: lazy-fetch ckb-US → render primary
2 email đồng thời, khách chưa có localeChỉ 1 GraphQL fetch (Redis lock)Trigger 2 email gần nhau, đếm GraphQL call / log lock
Custom HTML modeKhông override → giữ nguyên HTML (known limitation)Bật emailMode html, gửi → không bị trộn ngôn ngữ
Gemini fail khi add languageKhông persist rỗng, lock hết hạn → retry đượcMô phỏng lỗi Gemini, add language, kiểm tra translations không bị ghi {}
Send-test với real customerDùng customer.locale, không phải preview overrideSend-test chọn real customer có locale, đối chiếu ngôn ngữ email

6Follow-ups & known limitations