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 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.
Luồng A — gửi email theo locale của khách (subscribeSentEmailNotification.getEmailData):
Luồ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 khi thêm ngôn ngữ (translationController.createAdditionLanguage → fire-and-forget):
translations.{lang} — merge, không clobber| 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), gate bằng pointLabelLocale | Tránh phân kỳ: chỉ đổi label điểm khi thực sự render bản localized; mail test / html-mode giữ primary. |
| File | Thay đổi | ± |
|---|---|---|
services/customer/customerLocaleService.js | NEW — ensureCustomerLocale (lazy-fetch + lock) + resolveEmailContentByLocale (fallback chain) | NEW · 64 |
services/email/emailNotificationTranslationService.js | NEW — autoTranslateEmailNotifications (Gemini batch + dot-path save + rate-limit) | NEW · 74 |
handlers/pubsub/subscribeSentEmailNotification.js | getEmailData: resolve contentLocale, override 6 field + point label; wire lazy-fetch ở triggerSendEmail | +46 |
pages/Notifications/Edit.js | Language dropdown, localizedData overlay, handleChangeLocalized routing, preview-language ở send-test | +108 |
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 | Fire-and-forget autoTranslateEmailNotifications sau khi add language | +7 |
services/customer/syncCustomerService.js | transformCustomer: map locale: customer.locale || null | +3 |
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.
| # | Sev | Finding | Fix | Verified |
|---|---|---|---|---|
| 1 | HIGH | Reset button ghi đè nội dung primary khi đang xem ngôn ngữ phụ | Reset đi qua handleChangeLocalized (route đúng translations[locale]) | ✓ |
| 7 | HIGH | Save translations clobber cả map translations → mất ngôn ngữ khác | Update dot-path translations.{lang} | ✓ thêm de, giữ vi/fr |
| 5 | HIGH | Gemini trả {} → persist rỗng, mask lỗi + giữ lock 10' | Guard if (!Object.keys(translated).length) continue | ✓ |
| 2/3 | MED | Point label phân kỳ ngôn ngữ với nội dung email | Gate qua pointLabelLocale, dùng getTranslationForShop | ✓ |
| 4 | MED | Send-test "real customer" bỏ qua customer.locale | Omit previewLocale ở nhánh real-customer | ✓ |
| 6 | MED | HTML-mode trộn ngôn ngữ (override field trên custom HTML) | Skip override khi emailMode === 'html' | ✓ |
| 10 | LOW | Dropdown hiện cả ngôn ngữ đã tắt | Filter active !== false | ✓ |
| 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 |
| 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 |
yarn update-label (workflow nặng, không chặn).EmailSettingsV1) chưa có language Select — chỉ V2.