Technical handoff · for reviewers · SB-12028 / MR !4396
Migrate tích hợp Joy ↔ Joy Subscription từ product-based API (V1) → plan-based API (V2): đổi FK outer container + multi-product, single Joy admin UX, proactive sync. Kèm fix webhook activation-window (shared V1/V2) + activity log, và bộ auto-test webhook (Phase A–O, 61 PASS). Coexist V1 qua router gate; không breaking V1.
Bối cảnh: Joy Subscription migrate product-based API (subscriptionProducts/) → plan-based (subscriptionPlans/). Joy Loyalty phải đổi tích hợp theo. Data model 1 doc/sellingPlanId (per-frequency joyData) KHÔNG đổi — V1 đã ship pattern này ≥18 tháng (membershipPlanService.createMembershipPlansBatch batch N docs per N freq). V2 thực chất chỉ đổi: (1) FK outer subscriptionProductId → subscriptionPlanId; (2) multi-product per container (V1 forced 1:1 qua productId); (3) admin flow chuyển sang app JS.
Single collection + discriminator: membershipPlans/ doc với version:'v1'|'v2'; webhook lookup unified qua getMembershipPlanBySellingPlanId (works cả 2). joyData lưu flat top-level ở cả V1 lẫn V2 (verified: mọi V2 write path ...sanitizeJoyDataV2() spread). Joy là proxy: push cấu trúc sang JS V2 BE (vốn 0 validation → cần validatePayloadBeforeJs defense-in-depth), lưu joyData riêng.
Proactive sync: V1 dùng lazy reconcile (cleanupOrphans lúc GET) — đủ vì admin ở Joy app, ít xoá từ JS. V2 admin ở JS app giàu hơn → merchant edit/delete trực tiếp nhiều → lazy reconcile không kịp → thêm webhook sellingPlans_removed cascade. Đây là hệ quả admin-flow đổi, không phải "JS thiếu webhook" (V1 cũng không có).
Write path — admin save plan (create/update)
Webhook path — Joy Sub → Joy (lifecycle + delete)
| Quyết định | Phương án đã chọn | Vì sao (vs phương án khác) |
|---|---|---|
| Data model (1 doc/sellingPlanId) | GIỮ NGUYÊN pattern V1, chỉ đổi FK outer + thêm version:'v2' | Không phải design mới — V1 đã batch N docs per N freq ≥18 tháng. Cùng collection để unified webhook lookup; discriminator loại V1 tự nhiên (V1 thiếu version) |
| Multi-product container | V2 selectedItems[] (N product) vs V1 productId (forced 1:1) | Đây là khác biệt thực sự của plan-based API; FE browse modal + BE schema theo đó |
| Orphan detection | Lazy reconcile khi GET + thêm webhook delete cascade | V1 chỉ lazy reconcile là đủ (admin ở Joy, ít xoá từ JS). V2 admin ở JS → cần proactive; lazy reconcile giữ làm safety net |
| Dual-shape read (defensive) | readPlanField(plan, f) đọc plan[f] ?? plan.joyData?.[f] | Production cả V1 + V2 đều flat (plan[f]). Nhánh nested chỉ là defensive cho merge-path response shape; KHÔNG fix bug active. Áp dụng đồng bộ filter/sort/phase2-3/tag |
| Activity type cho webhook tier change | Reuse ACTION_UPDATE_TIER + field source/event/reason | Thêm type mới sẽ chạm Firestore IN-query 30-limit ở allActivitiesInAdminDisplay; reuse type + discriminate qua source/event an toàn hơn |
| Activation window source-of-truth | Đọc dates từ Joy doc, không từ webhook payload | prepareJoySubPayload strip joyData khi sync → payload không có dates → gate luôn pass. Joy doc mới là nguồn đúng (fix bug shared V1/V2) |
| sellingPlans_removed cascade | Branch isContainerDeleted: full vs partial-by-ids | JS Sub bắn theo SellingPlanGroup; full = xoá hết doc theo subscriptionPlanId, partial = xoá theo removedSellingPlanIds/joySubPlanIds (chunked IN-30) |
Diff thật của V2 (merge-base 8f2f5ce8 → 1ddd6253): 85 files, +11.7k/−41. Các file lõi:
| File | Thay đổi | ± |
|---|---|---|
controllers/membershipPlanV2Controller.js | REST handlers: CRUD + status + joyData fastpath + restore + orphans | NEW +341 |
services/joyMembershipV2Service.js | create/update/delete + R-OB-3 rollback + lazy reconcile | NEW +540 |
services/joySubV2IntegrationService.js | proxy POST/PUT/DELETE sang JS V2 BE | NEW +276 |
services/joyMigrationV1ToV2Service.js | migration V1→V2 (one-time) | NEW +223 |
repositories/membershipPlanV2Repository.js | per-frequency ops + coverage + orphan + cascade delete (full/by-ids) | NEW +255 |
helpers/validatePayloadBeforeJs.js | defense-in-depth validation + widget fields whitelist | NEW +284 |
middleware/ensureV2Shop.js | gate V2 routes theo JS plan-version (Redis 5-min cache, fail-closed) | NEW +50 |
services/joySubWebhookService.js | readPlanField dual-shape · isPlanDateValid gate · activity log · processSubscriptionPlanRemoved | +138 |
services/programService.js | processUpdate/UnassignExclusiveTier nhận source/event/reason + createActivity | +24 |
controllers/joySubWebhookController.js | topic subscription_plan/sellingPlans_removed dispatch | +10 |
helpers/sanitizeJoyDataV2.js · helpers/membershipPlanV2Errors.js | joyData sanitizer + error helper | NEW +65 |
services/shopify/shopifyCollectionsService.js | GraphQL listCollections cho browse modal | NEW +57 |
routes/api.js · config/joySub.js | đăng ký V2 routes + JS V2 endpoints | +81 |
firestore-indexes/membershipPlans.json | 3 composite index V2 (subscriptionPlanId / orphaned / joySubPlanId) | + |
pages/Membership/V2/)| File | Thay đổi | ± |
|---|---|---|
CreatePlanPage.js | create+edit form, single planData state, save bar, freq editor + preview pane + switch popover | NEW +665 |
PlansListPage.js | list: tabs/search/pagination/bulk delete | NEW +475 |
OrphanedPlansPage.js · OrphanedPlansAlert.js | orphan management + alert badge | NEW +267 |
JoyDataSection.js | per-frequency tier mapping (tier · priority · window · tag) | NEW +214 |
hooks/useBrowseProductsV2.js | product picker: collection filter, select-all, price | NEW +323 |
preview/* | storefront preview clone (PurchaseOptionsBlock, VariantChips, PriceLine, MembershipPlanPreview…) | NEW +550 |
WidgetSettingsSection.js · const/purchaseOptions.js | widget settings card mirror JS Sub | NEW +134 |
FrequencySwitchPopover.js | nút chuyển freq (mirror SelectPlanPopover) | NEW +70 |
FrequencyDetailView / SummaryCard / OptionEditor / RequirementsSection | freq sub-screens | NEW +397 |
helpers/planFormHelpers.js | default factories + structure-change detect | NEW +122 |
helpers/activity/prepareContent.js | render membership context cho webhook tier change | +26 |
locale/input/JoyMembershipV2.json (+10 outputs) | i18n namespace V2 + widget + activity keys | +241 |
scripts/seed-joy-sub-webhook/)| File | Thay đổi | ± |
|---|---|---|
run-suite.js | Phase A–O runner (61 scenarios) | NEW +1509 |
fixtures.js · payloads.js · sign.js · check.js · log-tail.js | seed/HMAC/assert helpers + seedV2Plan | NEW +396 |
seed-joy-sub-webhook.js (CLI) · README.md · test-plan.md | CLI entry + docs + 61-scenario plan | NEW +525 |
1 bug thật (shared V1/V2) + 1 cải tiến audit + 1 hardening defensive. Tất cả verify qua auto-test.
| # | Loại | Nội dung | Fix | Verified |
|---|---|---|---|---|
| 1 | BUG | Activation window bị ignore (ảnh hưởng cả V1 + V2, shared handler) — isPlanDateValid đọc dates từ webhook payload, nhưng prepareJoySubPayload strip joyData khi Joy sync sang JS → payload echo không có dates → gate luôn pass. Plan hết hạn vẫn promote, chưa tới ngày cũng promote. | Đọc dates trực tiếp Joy doc (source-of-truth), không từ payload | ✓ Phase C5/C6 |
| 2 | CẢI TIẾN | Tier change qua webhook không ghi activity (ASSIGN thiếu createActivity; UNASSIGN dùng source=admin sai) → CS/admin không truy được lịch sử | Cả 2 path pass source=JS_SUB_WEBHOOK + event + reason{contractId,sellingPlanId,planTitle}; UI render context | ✓ Phase N (C1/C2/C3 UI) |
| 3 | DEFENSIVE | Không phải bug active. readPlanField dual-shape (flat ?? nested) phòng trường hợp merge-path trả nested joyData. Production V2 doc lưu flat y hệt V1 nên code cũ vẫn đọc đúng — fix này là hardening, đi kèm khi refactor activity log. | readPlanField áp dụng đồng bộ filter/sort/phase2-3/tag | ✓ Phase K1 + C |
Suite backend chạy local emulator → staging Firestore. 61 PASS / 0 fail / ~13 skip (cần fault injection hoặc real Shopify customer).
| Phase | Coverage | Notable cases |
|---|---|---|
| A–B | Middleware + dispatch | missing header 400, wrong key 401, bad HMAC 401, idempotent replay, unknown shop 503, 7 topic routing |
| C–D | findBestMembershipPlan + validation | empty plans, unknown sellingPlan, no tier, future-start/past-end gate (Bug 1), nested joyData (Bug 2) |
| E–G | Contract/priority + tier + door dedup | priority skip/proceed, tier-not-found, fallback, out-of-order timestamp block |
| H–J | Tier change side effects + tag + integration | already-assigned skip, unassign fallback, tag add/remove, happy path per event |
| K–M | Edge + priority deep + multi-tier | flat vs nested regression, priority 0, multi-plan sort, Silver→Subscriber switch, flip-flop |
| N | Per-field REAL customers | C1 assign / C2 unassign-fallback / C3 switch — verify Firestore state + activity feed UI |
| O | sellingPlans_removed cascade | full container delete, partial by sellingPlanIds, partial by joySubPlanIds, replay idempotent, missing-planId defensive skip |
node scripts/seed-joy-sub-webhook/run-suite.js --phase=all với emulator + staging service account. Hoặc test thật sau khi đấu staging Joy ↔ Joy Sub.prepareJoySubPayload, chưa test qua JS Sub thật) pending stagingcustomerActivities, không qua customer doc