Technical handoff · for reviewers · JOY-260513-gA2AP6
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ữ.
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.
Luồng A — gửi email theo locale của khách (subscribeSentEmailNotification.getEmailData):
customer.locale với additionalLanguages (gate: chỉ localize ngôn ngữ merchant đã bật)Resolution 3 tầng (mỗi field translatable, gate đã qua additionalLanguages):
translations[lang][field] — merchant sửa / cache nềnemailDefaultTranslationService tra event.field + hash → 0 GeminiLuồng B — lazy-fetch locale (customerLocaleService.ensureCustomerLocale):
locale-fetch:{shop}:{cust}, TTL 30s — chống fetch trùngcustomer(id){ locale }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):
translateNotificationEmail · 1 notification (event) → bounded, không timeoutautoTranslateEmailNotifications · loop cả shopbackgroundHandlingLight (512MiB)subscribeBackgroundHandling (handler monolithic, live); lock per shop+lang(+event); Gemini field custom; static lo defaulttranslations.{lang} — merge, không clobberbackgroundHandling* 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):
defaultNotifications + 3 bộ + versions → _sources.json (17 events)event.field + hash · commit vào repo| Quyết định | Chọn | Vì sao (vs phương án khác) |
|---|---|---|
| Nguồn locale | Lazy-fetch GraphQL, 1 lần/khách | REST + 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ùng | Redis setNX lock + sentinel localeFetchedAt | 2 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-locale | translations[locale] map trên notification doc, update dot-path | Dot-path update({'translations.fr': x}) merge nested, không ghi đè sibling (vi/de). Đã verify thêm de không mất vi/fr. |
| Fallback resolution | exact → language-only → regional sibling → primary | fr-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.locale → getTranslationForShop 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-HK → zh-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 (ko→ko-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 trong | Khô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/Sub | publish backgroundHandlingLight → worker, thay vì .catch() fire-and-forget trong request | Serverless (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-notification | render Tier-3 enqueue translateNotificationEmail cho ĐÚNG 1 notification (event), không loop cả shop | Mỗ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/heavy | Cá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). |
| File | Thay đổi | ± |
|---|---|---|
services/customer/customerLocaleService.js | NEW — ensureCustomerLocale (lazy-fetch + lock) + matchLocaleKey (fallback chain, trả về key đã match) | NEW · 60 |
services/shopifyService.js | getShopifyCustomerLocale — gom GraphQL fetch customer.locale về service Shopify (dùng bởi ensureCustomerLocale) | +15 |
services/emailDefaultTranslationService.js | NEW — translation-memory tĩnh: index 17 events theo event.field+hash, isDefaultEmailString, getDefaultEmailTranslation (loader cache) | NEW |
services/email/emailNotificationTranslationService.js | NEW — 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/DONE | NEW |
handlers/pubsub/subscribeSentEmailNotification.js | getEmailData: 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.js | Dispatch case autoTranslateEmailNotifications (eager) + translateNotificationEmail (lazy per-notification) trong handler monolithic live | +28 |
pages/Notifications/Edit.js | Language 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.js | Endpoint 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.js | Language Select card + remount RichTextEditor theo contentLocaleKey | +19 |
services/optimize/bulkOperationService.js | Thêm locale vào CUSTOMERS_QUERY + CUSTOMERS_QUERY_B2B | +4 |
const/customers.js | CUSTOMER_LOCALE + thêm locale vào shopifyCustomerUpdateFields | +4 |
controllers/translationController.js | Publish backgroundHandlingLight (pubsub) sau khi add language — thay fire-and-forget | +8 |
services/customer/syncCustomerService.js | transformCustomer: map locale: customer.locale || null | +3 |
src/scripts/dumpEmailDefaultSources.js | NEW — dump source strings của 4 bộ default + versions → _sources.json/_targets.json (esbuild, không AI dep) | NEW |
scripts/translateEmailDefaultTranslations.js | NEW — Gemini per-lang (mirror autoTranslateV2): numeric-key batch, candidate guard, auth/rate-limit/split, incremental theo slot | NEW |
storage/emailDefaultTranslations/*.json | NEW — 39 lang + en, 88 event.field keys / 94 slots; _sources.json = bảng tra audit | NEW · 42 files |
✅ Đã verify E2E trên emulator (real send theo customer.locale → đọc emailNotificationLogs, render thật). Setup: 80 - Work/Joy/emulator-e2e-setup.md.
| # | Case | Render thật | Verdict |
|---|---|---|---|
| C1 | vi · default | subject+body VI (Bạn đã kiếm được…) | ✓ tier-2 static |
| C2 | fr · default | FR (Vous avez gagné…) | ✓ static |
| C3 | ko-US · default (region-mismatch) | KO (100 points를 적립했습니다!) | ✓ ko-US→ko |
| C4 | vi · stored subject | subject = stored | ✓ tier-1 > static |
| C5 | vi · custom subject (no trans) | subject primary + body VI | ✓ tier-3 per-field |
| C6 | vi · HTML mode | customHtml primary (EN) | ✓ footgun |
| C7 | ja · không trong additionalLanguages | primary (EN) | ✓ gate |
| C8 | field 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):
| Case | Expected | Cách test (QA) |
|---|---|---|
Locale có vùng (fr-CA), chỉ có bản fr | Khớp sibling → render bản fr | Set customer locale fr-CA, có translations[fr], gửi → subject tiếng Pháp |
Locale lạ (ckb-US), không có bản nào | Fallback về primary language | Đã verify dev: lazy-fetch ckb-US → render primary |
Regional-variant lệch market (zh-HK), shop có bản zh-TW | Body và 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ó locale | Chỉ 1 GraphQL fetch (Redis lock) | Trigger 2 email gần nhau, đếm GraphQL call / log lock |
| Custom HTML mode | Khô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 language | Không persist rỗng, lock hết hạn → retry được | Mô phỏng lỗi Gemini, add language, kiểm tra translations không bị ghi {} |
| Send-test với real customer | Dùng customer.locale, không phải preview override | Send-test chọn real customer có locale, đối chiếu ngôn ngữ email |
| Shop dùng nguyên template mặc định + có additionalLanguages | Render ra ngôn ngữ khách từ bản tĩnh, 0 Gemini call | Shop 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ửi | Field sửa: email này ra primary + enqueue backfill; email sau ra bản dịch. Field default khác: tĩnh ngay | Sử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 job | Shop cũ có translations rỗng, content = default → render tĩnh; field custom → lazy backfill |
| Thêm notification template / version mới | Re-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ũ) |
EmailSettingsV1) — thêm language Select (data routing đã đúng, V1 đang primary-only).yarn update-label.additionalLanguages — render gọi getTranslationByShopId = +1 read / email localized, chưa cache.ar/he-IL, HTML phức tạp). Spot-check vài ngôn ngữ trước prod.dump + translate (incremental); entry tĩnh template cũ thành orphan vô hại.en-US→en-GB); setNX fail-open khi Redis down; lazy-fetch lỗi không set sentinel → retry mỗi email; validation editor chỉ primary.