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 6 commits · pushed · MR !4520 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 render nội dung email theo locale.

Render dùng resolution 3 tầng theo từng field để tối ưu chi phí Gemini: (1) bản dịch merchant đã lưu/cache trên notification doc → (2) bản dịch tĩnh của template mặc định (ship sẵn trong repo, 0 Gemini) → (3) field merchant đã sửa mà chưa có bản dịch → render primary + enqueue dịch nền cho lần sau. Vì đa số shop dùng nguyên template mặc định, phần lớn email rơi vào tầng 2 → không tốn Gemini và phủ cả shop hiện hữu mà không cần migration.

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
matchLocaleKey
match customer.locale với additionalLanguages (gate: chỉ localize ngôn ngữ merchant đã bật)
Resolve 3 tầng / field
stored → static → primary + lazy
Render + send
override 6 field + point label theo key đã match

Resolution 3 tầng (mỗi field translatable, gate đã qua additionalLanguages):

Tier 1 · stored
translations[lang][field] — merchant sửa / cache nền
Tier 2 · static
emailDefaultTranslationService tra event.field + hash → 0 Gemini
Tier 3 · primary + lazy
field đã sửa, chưa dịch → primary + enqueue backfill per-notification

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 (Pub/Sub worker, không fire-and-forget). 2 đường, dùng chung core translateNotificationFields (chỉ Gemini field !isDefaultEmailString):

Lazy (render Tier-3)
publish translateNotificationEmail · 1 notification (event) → bounded, không timeout
Eager (add-language)
publish autoTranslateEmailNotifications · loop cả shop
Topic backgroundHandlingLight (512MiB)
dispatch trong subscribeBackgroundHandling (handler monolithic, live); lock per shop+lang(+event); Gemini field custom; static lo default
Save dot-path
translations.{lang} — merge, không clobber
Design note (wiring): mọi topic backgroundHandling* route qua handler monolithic subscribeBackgroundHandling.js — các file split lightHandler.js/mediumHandler.js chưa wire (refactor backlog) → case worker mới phải đặt ở subscribeBackgroundHandling.js, không phải file split. Worker có success-log (ENTER/lock/count/translated/SAVED/DONE) để phân biệt chạy thật vs no-op.

Luồng D — sinh bản dịch tĩnh mặc định (offline, 1 lần / khi đổi template):

dump (esbuild)
defaultNotifications + 3 bộ + versions → _sources.json (17 events)
translate (Gemini)
incremental: chỉ dịch chuỗi mới, dedup theo hash
storage/<lang>.json
39 lang · key event.field + hash · commit vào repo

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) với pointLabelLocale = key đã match của body (không phải raw locale)Trước fix: truyền raw customer.localegetTranslationForShop match thô bằng includes, rớt về English khi region của locale lệch market (vd zh-HK không có trong list locale). Sau fix: matchLocaleKey trả về chính key mà body đã chọn (zh-HKzh-TW), point-label bám đúng key đó → đồng nhất ngôn ngữ với nội dung, độc lập với quy tắc append region không nhất quán của Shopify (koko-US nhưng zh-TW giữ nguyên). Vẫn gate: mail test / html-mode giữ primary.
Tầng tĩnh cho default (tier 2)Ship sẵn bản dịch của template mặc định trong repo; render tra tĩnh trước khi nghĩ tới GeminiĐa số shop (nhất là tier thấp) không sửa template mặc định → cùng English ở mọi shop → dịch sẵn 1 lần dùng chung. Tránh gọi Gemini lặp lại cùng nội dung per-shop; phủ cả shop hiện hữu không cần migration job. Chỉ field merchant thực sự sửa mới rơi xuống tier-3 (lazy Gemini).
Key file tĩnh"event.field" (đọc được) + hash của source bên trongKhông thể dùng key event.field trần như label admin: một field có nhiều version nội dung khác nhau (notificationVersions) + phải phân biệt đã sửa hay chưa — cả hai buộc match theo nội dung. Hash giải quyết version/edit; prefix event.field cho dev grep/audit. Render build key từ doc.event + field + hash(content). File generated, không sửa tay; _sources.json là bảng tra hash↔English.
Auto-translate = Pub/Subpublish backgroundHandlingLight → worker, thay vì .catch() fire-and-forget trong requestServerless (Cloud Functions) freeze instance sau khi trả HTTP response → loop Gemini dễ bị cắt giữa chừng, dịch dở + giữ lock. Worker có vòng đời riêng (540s, CPU đảm bảo). Đúng pattern Joy đã dùng cho FAQ/widget translation.
Lazy backfill = per-notificationrender Tier-3 enqueue translateNotificationEmail cho ĐÚNG 1 notification (event), không loop cả shopMỗi task bound 1 notification → không bao giờ chạm 540s dù shop customize bao nhiêu. Lock per shop+lang+event. Eager add-language vẫn loop full set (dùng chung core). (Best-practice cân nhắc: scale nhỏ + cần done-signal → lazy-first/self-continuation hợp hơn Cloud Tasks fan-out.)
Worker tier = light (512MiB)KHÔNG dùng medium/heavyCác tier chỉ khác RAM (light 512MiB · medium 1GiB · heavy 4GiB), timeout 540s như nhau → medium không lợi gì cho timeout. Translation là text/IO-bound, không ngốn RAM. Cùng class với FAQ-translation (đã ở light).

3Files changed

FileThay đổi±
services/customer/customerLocaleService.jsNEWensureCustomerLocale (lazy-fetch + lock) + matchLocaleKey (fallback chain, trả về key đã match)NEW · 60
services/shopifyService.jsgetShopifyCustomerLocale — gom GraphQL fetch customer.locale về service Shopify (dùng bởi ensureCustomerLocale)+15
services/emailDefaultTranslationService.jsNEW — translation-memory tĩnh: index 17 events theo event.field+hash, isDefaultEmailString, getDefaultEmailTranslation (loader cache)NEW
services/email/emailNotificationTranslationService.jsNEW — core translateNotificationFields (chỉ Gemini field !isDefaultEmailString, dot-path save) dùng chung bởi autoTranslateEmailNotifications (eager, full shop) + translateNotificationEmail (lazy, 1 notification). + success-log ENTER/lock/count/translated/SAVED/DONENEW
handlers/pubsub/subscribeSentEmailNotification.jsgetEmailData: gate qua additionalLanguages → resolve 3 tầng / field (stored→static→primary) → override 6 field + point label; Tier-3 publish backfill; wire lazy-fetch ở triggerSendEmail+89
handlers/pubsub/subscribeBackgroundHandling.jsDispatch case autoTranslateEmailNotifications (eager) + translateNotificationEmail (lazy per-notification) trong handler monolithic live+28
pages/Notifications/Edit.jsLanguage dropdown, localizedData overlay (localeContent[f] ?? staticContent[f] ?? data[f]), handleChangeLocalized routing, preview-language ở send-test; fetch static-translations khi mở tab ngôn ngữ phụ để hiển thị bản tĩnh (display-only, chỉ persist khi merchant sửa)+108
controllers/notificationController.js + routes/api.jsEndpoint GET /lp/notification/:id/static-translations?locale= — trả bản dịch tĩnh (tier-2) cho editor hiển thị ngôn ngữ phụ trên content default+~30
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.jsPublish backgroundHandlingLight (pubsub) sau khi add language — thay fire-and-forget+8
services/customer/syncCustomerService.jstransformCustomer: map locale: customer.locale || null+3
src/scripts/dumpEmailDefaultSources.jsNEW — dump source strings của 4 bộ default + versions → _sources.json/_targets.json (esbuild, không AI dep)NEW
scripts/translateEmailDefaultTranslations.jsNEW — Gemini per-lang (mirror autoTranslateV2): numeric-key batch, candidate guard, auth/rate-limit/split, incremental theo slotNEW
storage/emailDefaultTranslations/*.jsonNEW — 39 lang + en, 88 event.field keys / 94 slots; _sources.json = bảng tra auditNEW · 42 files

4Edge cases & verify

Đã verify E2E trên emulator (real send theo customer.locale → đọc emailNotificationLogs, render thật). Setup: 80 - Work/Joy/emulator-e2e-setup.md.

#CaseRender thậtVerdict
C1vi · defaultsubject+body VI (Bạn đã kiếm được…)✓ tier-2 static
C2fr · defaultFR (Vous avez gagné…)✓ static
C3ko-US · default (region-mismatch)KO (100 points를 적립했습니다!)✓ ko-US→ko
C4vi · stored subjectsubject = stored✓ tier-1 > static
C5vi · custom subject (no trans)subject primary + body VI✓ tier-3 per-field
C6vi · HTML modecustomHtml primary (EN)✓ footgun
C7ja · không trong additionalLanguagesprimary (EN)✓ gate
C8field custom (body sửa) · add fr/es/vi → eager worker (Gemini)translations.{lang}.notificationContent = bản dịch (placeholder {{…}} giữ nguyên); editor render đủ ngôn ngữ✓ tier-3 Gemini

QA test plan (đầy đủ, gồm trường hợp cần Gemini/storefront):

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
Regional-variant lệch market (zh-HK), shop có bản zh-TWBody point-label cùng render zh-TW (không lệch English)Set customer locale zh-HK, có translations[zh-TW], gửi → cả nội dung lẫn nhãn điểm tiếng Trung
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
Shop dùng nguyên template mặc định + có additionalLanguagesRender ra ngôn ngữ khách từ bản tĩnh, 0 Gemini callShop có vi, customer locale vi, notification chưa sửa → gửi → tiếng Việt; log không có Gemini call
Merchant sửa 1 field (vd subject) rồi gửiField sửa: email này ra primary + enqueue backfill; email sau ra bản dịch. Field default khác: tĩnh ngaySửa subject 1 notification, gửi lần 1 (subject primary) → lần 2 (subject đã dịch); body vẫn tĩnh cả 2 lần
Shop hiện hữu (đã add lang trước feature)Tự phủ qua tĩnh + lazy, không cần migration jobShop cũ có translations rỗng, content = default → render tĩnh; field custom → lazy backfill
Thêm notification template / version mớiRe-run dump + translate → chỉ dịch chuỗi mới (incremental)Thêm vào defaultNotifications → dump → translate (log +N slots mới, không đụng cũ)

5Follow-ups & improvement tasks

Task mở rộng (gợi mở)

Known limitations & rủi ro cần kiểm