@mohasinac/appkit 4.62.0 → 4.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +8 -29
- package/dist/_internal/client/features/layout/filterNavItems.d.ts +28 -0
- package/dist/_internal/client/features/layout/filterNavItems.js +35 -0
- package/dist/_internal/server/features/ads/booking.d.ts +118 -0
- package/dist/_internal/server/features/ads/booking.js +326 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +129 -9
- package/dist/_internal/server/features/checkout/actions.js +850 -79
- package/dist/_internal/server/features/checkout/expand-order-items.d.ts +1 -2
- package/dist/_internal/server/features/checkout/expand-order-items.js +32 -0
- package/dist/_internal/server/features/checkout/freeze-parts.d.ts +50 -0
- package/dist/_internal/server/features/checkout/freeze-parts.js +94 -0
- package/dist/_internal/server/features/checkout/value-otp-gate.d.ts +55 -0
- package/dist/_internal/server/features/checkout/value-otp-gate.js +60 -0
- package/dist/_internal/server/features/dashboard/attention.d.ts +128 -0
- package/dist/_internal/server/features/dashboard/attention.js +183 -0
- package/dist/_internal/server/features/dashboard/data.d.ts +47 -0
- package/dist/_internal/server/features/dashboard/data.js +82 -0
- package/dist/_internal/server/features/inventory/reservations.d.ts +99 -0
- package/dist/_internal/server/features/inventory/reservations.js +273 -0
- package/dist/_internal/server/features/invoicing/allocate.d.ts +63 -0
- package/dist/_internal/server/features/invoicing/allocate.js +162 -0
- package/dist/_internal/server/features/invoicing/credit-note.d.ts +57 -0
- package/dist/_internal/server/features/invoicing/credit-note.js +126 -0
- package/dist/_internal/server/features/invoicing/gstr1.d.ts +74 -0
- package/dist/_internal/server/features/invoicing/gstr1.js +251 -0
- package/dist/_internal/server/features/invoicing/issue.d.ts +96 -0
- package/dist/_internal/server/features/invoicing/issue.js +202 -0
- package/dist/_internal/server/features/offers/destinations.d.ts +40 -0
- package/dist/_internal/server/features/offers/destinations.js +65 -0
- package/dist/_internal/server/features/orders/actions.d.ts +8 -1
- package/dist/_internal/server/features/orders/actions.js +81 -8
- package/dist/_internal/server/features/orders/adapters.js +54 -0
- package/dist/_internal/server/features/orders/amend-delivery.d.ts +56 -0
- package/dist/_internal/server/features/orders/amend-delivery.js +124 -0
- package/dist/_internal/server/features/orders/balance.d.ts +89 -0
- package/dist/_internal/server/features/orders/balance.js +256 -0
- package/dist/_internal/server/features/orders/cancellation-refund.d.ts +15 -9
- package/dist/_internal/server/features/orders/cancellation-refund.js +15 -9
- package/dist/_internal/server/features/points/award.js +46 -4
- package/dist/_internal/server/features/pricing/actions.d.ts +58 -0
- package/dist/_internal/server/features/pricing/actions.js +144 -0
- package/dist/_internal/server/features/refunds/actions.js +26 -0
- package/dist/_internal/server/features/shipping/book-split.d.ts +86 -0
- package/dist/_internal/server/features/shipping/book-split.js +130 -0
- package/dist/_internal/server/features/shipping/book.d.ts +61 -0
- package/dist/_internal/server/features/shipping/book.js +176 -0
- package/dist/_internal/server/features/shipping/courier-block.d.ts +69 -0
- package/dist/_internal/server/features/shipping/courier-block.js +264 -0
- package/dist/_internal/server/features/site-settings/adapters.d.ts +2 -2
- package/dist/_internal/server/features/site-settings/adapters.js +29 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +65 -0
- package/dist/_internal/server/functions/scheduled.d.ts +2 -1
- package/dist/_internal/server/functions/scheduled.js +95 -2
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +11 -2
- package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +4 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.js +19 -2
- package/dist/_internal/server/jobs/core/index.d.ts +2 -1
- package/dist/_internal/server/jobs/core/index.js +2 -1
- package/dist/_internal/server/jobs/core/jobRunners.js +45 -0
- package/dist/_internal/server/jobs/core/onJobCreated.d.ts +30 -7
- package/dist/_internal/server/jobs/core/onJobCreated.js +78 -45
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +7 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +65 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +33 -0
- package/dist/_internal/server/jobs/core/reservationExpiry.d.ts +17 -0
- package/dist/_internal/server/jobs/core/reservationExpiry.js +40 -0
- package/dist/_internal/server/jobs/core/runJobOnce.d.ts +48 -0
- package/dist/_internal/server/jobs/core/runJobOnce.js +195 -0
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +25 -2
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/onOrderStatusChange.js +17 -0
- package/dist/_internal/server/jobs/handlers/reservationExpiry.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/reservationExpiry.js +2 -0
- package/dist/_internal/server/notifications/channel-health.d.ts +26 -3
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +296 -0
- package/dist/_internal/shared/checkout/coupon-discount.d.ts +43 -1
- package/dist/_internal/shared/checkout/coupon-discount.js +26 -1
- package/dist/_internal/shared/checkout/order-math.d.ts +174 -0
- package/dist/_internal/shared/checkout/order-math.js +159 -0
- package/dist/_internal/shared/checkout/refundable-base.d.ts +25 -0
- package/dist/_internal/shared/checkout/refundable-base.js +13 -1
- package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
- package/dist/_internal/shared/checkout/rules/index.js +1 -1
- package/dist/_internal/shared/checkout/rules/payment-gate.d.ts +18 -0
- package/dist/_internal/shared/checkout/rules/payment-gate.js +25 -0
- package/dist/_internal/shared/checkout/split.d.ts +75 -0
- package/dist/_internal/shared/checkout/split.js +120 -0
- package/dist/_internal/shared/content/disclaimers.d.ts +88 -0
- package/dist/_internal/shared/content/disclaimers.js +217 -0
- package/dist/_internal/shared/content/published-values.d.ts +93 -0
- package/dist/_internal/shared/content/published-values.js +206 -0
- package/dist/_internal/shared/features/admin/integrations.d.ts +139 -0
- package/dist/_internal/shared/features/admin/integrations.js +263 -0
- package/dist/_internal/shared/features/dashboard/hub-sections.d.ts +72 -0
- package/dist/_internal/shared/features/dashboard/hub-sections.js +75 -0
- package/dist/_internal/shared/features/emi/choice.d.ts +98 -0
- package/dist/_internal/shared/features/emi/choice.js +69 -0
- package/dist/_internal/shared/features/emi/group.d.ts +84 -0
- package/dist/_internal/shared/features/emi/group.js +126 -0
- package/dist/_internal/shared/features/ledger/posting.d.ts +107 -0
- package/dist/_internal/shared/features/ledger/posting.js +165 -0
- package/dist/_internal/shared/features/ledger/reports.d.ts +76 -0
- package/dist/_internal/shared/features/ledger/reports.js +127 -0
- package/dist/_internal/shared/features/ledger/rules.d.ts +170 -0
- package/dist/_internal/shared/features/ledger/rules.js +221 -0
- package/dist/_internal/shared/features/offers/coordination.d.ts +122 -0
- package/dist/_internal/shared/features/offers/coordination.js +80 -0
- package/dist/_internal/shared/features/orders/delivery-snapshot.d.ts +114 -0
- package/dist/_internal/shared/features/orders/delivery-snapshot.js +116 -0
- package/dist/_internal/shared/features/orders/dispute-reasons.d.ts +113 -0
- package/dist/_internal/shared/features/orders/dispute-reasons.js +181 -0
- package/dist/_internal/shared/features/orders/refund-copy.d.ts +1 -1
- package/dist/_internal/shared/features/orders/refund-copy.js +18 -1
- package/dist/_internal/shared/features/payouts/pay-period.d.ts +91 -0
- package/dist/_internal/shared/features/payouts/pay-period.js +104 -0
- package/dist/_internal/shared/features/points/accepts.d.ts +29 -0
- package/dist/_internal/shared/features/points/accepts.js +49 -0
- package/dist/_internal/shared/features/pricing/rate-registry.d.ts +150 -0
- package/dist/_internal/shared/features/pricing/rate-registry.js +246 -0
- package/dist/_internal/shared/features/shipping/parcel.d.ts +123 -0
- package/dist/_internal/shared/features/shipping/parcel.js +139 -0
- package/dist/_internal/shared/features/shipping/shared-purchase.d.ts +91 -0
- package/dist/_internal/shared/features/shipping/shared-purchase.js +108 -0
- package/dist/_internal/shared/features/site-settings/fees.d.ts +16 -8
- package/dist/_internal/shared/features/site-settings/fees.js +12 -8
- package/dist/_internal/shared/features/site-settings/types.d.ts +11 -0
- package/dist/_internal/shared/fees/calculator.d.ts +33 -1
- package/dist/_internal/shared/fees/calculator.js +32 -2
- package/dist/_internal/shared/fees/explain.d.ts +81 -0
- package/dist/_internal/shared/fees/explain.js +101 -0
- package/dist/_internal/shared/history/index.d.ts +1 -1
- package/dist/_internal/shared/history/index.js +1 -1
- package/dist/_internal/shared/history/types.d.ts +7 -1
- package/dist/_internal/shared/history/types.js +25 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +43 -0
- package/dist/constants/api-endpoints.d.ts +114 -0
- package/dist/constants/api-endpoints.js +55 -0
- package/dist/constants/field-names.d.ts +42 -0
- package/dist/constants/field-names.js +52 -0
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/shipping.d.ts +45 -0
- package/dist/contracts/shipping.js +15 -0
- package/dist/features/about/components/AboutView.js +1 -1
- package/dist/features/account/components/BuyerAuctionsGuideView.js +4 -1
- package/dist/features/account/components/UserOrdersView.js +47 -35
- package/dist/features/admin/components/AdminCouponEditorView.js +17 -0
- package/dist/features/admin/components/AdminIntegrationsView.d.ts +55 -0
- package/dist/features/admin/components/AdminIntegrationsView.js +74 -0
- package/dist/features/admin/components/AdminInvoicingView.d.ts +2 -0
- package/dist/features/admin/components/AdminInvoicingView.js +65 -0
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +33 -1
- package/dist/features/admin/components/AdminOrderEditorView.js +81 -3
- package/dist/features/admin/components/AdminOrdersGuideView.js +23 -2
- package/dist/features/admin/components/AdminOrdersView.js +86 -5
- package/dist/features/admin/components/OwnerQuickActions.js +24 -2
- package/dist/features/admin/constants/filter-tabs.d.ts +6 -0
- package/dist/features/admin/constants/filter-tabs.js +6 -0
- package/dist/features/admin/constants/site-settings-tabs.d.ts +1 -1
- package/dist/features/admin/hooks/useAdminListingData.js +16 -0
- package/dist/features/admin/schemas/admin-user-form.d.ts +2 -2
- package/dist/features/admin/schemas/firestore.d.ts +62 -0
- package/dist/features/admin/schemas/firestore.js +5 -0
- package/dist/features/ads/schemas/booking-write.d.ts +120 -0
- package/dist/features/ads/schemas/booking-write.js +75 -0
- package/dist/features/ads/schemas/creative-form.d.ts +18 -0
- package/dist/features/ads/schemas/creative-form.js +51 -0
- package/dist/features/ads/schemas/firestore.d.ts +140 -0
- package/dist/features/ads/schemas/firestore.js +109 -0
- package/dist/features/auctions/repository/bid.repository.js +3 -1
- package/dist/features/audit-log/schemas/firestore.d.ts +140 -0
- package/dist/features/audit-log/schemas/firestore.js +89 -0
- package/dist/features/auth/actions/profile-actions.d.ts +69 -1
- package/dist/features/auth/actions/profile-actions.js +62 -1
- package/dist/features/auth/permissions/constants.js +13 -0
- package/dist/features/auth/schemas/firestore.d.ts +16 -2
- package/dist/features/auth/schemas/firestore.js +2 -1
- package/dist/features/blog/actions/blog-actions.d.ts +6 -0
- package/dist/features/blog/actions/blog-actions.js +17 -2
- package/dist/features/blog/api/route.js +4 -1
- package/dist/features/blog/columns/index.js +5 -0
- package/dist/features/blog/components/BlogIndexPageView.js +2 -1
- package/dist/features/blog/repository/blog.repository.d.ts +4 -0
- package/dist/features/blog/repository/blog.repository.js +5 -0
- package/dist/features/blog/schemas/blog-form.d.ts +3 -0
- package/dist/features/blog/schemas/blog-form.js +1 -0
- package/dist/features/blog/schemas/firestore.d.ts +38 -1
- package/dist/features/blog/schemas/firestore.js +23 -0
- package/dist/features/blog/schemas/index.d.ts +6 -0
- package/dist/features/blog/schemas/index.js +2 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +25 -1
- package/dist/features/cart/components/CartPriceBreakdown.js +29 -6
- package/dist/features/cart/components/PointsTenderPicker.d.ts +61 -0
- package/dist/features/cart/components/PointsTenderPicker.js +15 -0
- package/dist/features/cart/components/StoreAddonsPicker.js +2 -1
- package/dist/features/cart/components/index.d.ts +2 -0
- package/dist/features/cart/components/index.js +1 -0
- package/dist/features/cart/repository/cart.repository.d.ts +33 -0
- package/dist/features/cart/repository/cart.repository.js +47 -0
- package/dist/features/cart/schemas/firestore.d.ts +116 -0
- package/dist/features/contact/email.d.ts +27 -10
- package/dist/features/contact/email.js +20 -3
- package/dist/features/dashboard/components/DashboardHubView.d.ts +83 -0
- package/dist/features/dashboard/components/DashboardHubView.js +56 -0
- package/dist/features/dashboard/components/DashboardSectionsClient.d.ts +23 -0
- package/dist/features/dashboard/components/DashboardSectionsClient.js +66 -0
- package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.d.ts +55 -0
- package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.js +175 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +62 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +108 -0
- package/dist/features/dispute-reasons/schemas/firestore.d.ts +62 -0
- package/dist/features/dispute-reasons/schemas/firestore.js +40 -0
- package/dist/features/events/components/EventSaleBanner.js +1 -1
- package/dist/features/faq/components/FAQAccordion.d.ts +13 -1
- package/dist/features/faq/components/FAQAccordion.js +29 -4
- package/dist/features/faq/components/FAQPageContent.js +2 -2
- package/dist/features/gateway-events/schemas/index.d.ts +2 -2
- package/dist/features/homepage/schemas/index.d.ts +8 -8
- package/dist/features/inventory/schemas/stock-reservation.d.ts +114 -0
- package/dist/features/inventory/schemas/stock-reservation.js +103 -0
- package/dist/features/invoicing/schemas/firestore.d.ts +112 -0
- package/dist/features/invoicing/schemas/firestore.js +45 -0
- package/dist/features/invoicing/utils/financial-year.d.ts +43 -0
- package/dist/features/invoicing/utils/financial-year.js +53 -0
- package/dist/features/jobs/actions/enqueue-job.js +18 -1
- package/dist/features/jobs/repository/jobs.repository.d.ts +62 -1
- package/dist/features/jobs/repository/jobs.repository.js +84 -1
- package/dist/features/jobs/schemas/firestore.d.ts +80 -0
- package/dist/features/jobs/schemas/firestore.js +37 -0
- package/dist/features/layout/AppLayoutShell.d.ts +9 -1
- package/dist/features/layout/AppLayoutShell.js +12 -4
- package/dist/features/layout/BottomActions.js +2 -1
- package/dist/features/layout/BottomNavbar.d.ts +10 -2
- package/dist/features/layout/SideActionDock.js +52 -2
- package/dist/features/layout/TitleBarLayout.js +13 -4
- package/dist/features/layout/TitleBarLayout.style.css +59 -0
- package/dist/features/layout/useCtaReveal.d.ts +16 -1
- package/dist/features/layout/useCtaReveal.js +17 -26
- package/dist/features/ledger/repository/journal.repository.d.ts +110 -0
- package/dist/features/ledger/repository/journal.repository.js +312 -0
- package/dist/features/ledger/schemas/expenses.d.ts +87 -0
- package/dist/features/ledger/schemas/expenses.js +27 -0
- package/dist/features/ledger/schemas/firestore.d.ts +183 -0
- package/dist/features/ledger/schemas/firestore.js +69 -0
- package/dist/features/lottery/schemas/zod.d.ts +6 -6
- package/dist/features/media/MediaImage.js +22 -1
- package/dist/features/media/upload/ImageUpload.js +1 -1
- package/dist/features/obligations/constants/policies.js +24 -1
- package/dist/features/orders/components/BalancePaymentPanel.d.ts +12 -0
- package/dist/features/orders/components/BalancePaymentPanel.js +94 -0
- package/dist/features/orders/components/OrderPaymentSummary.js +10 -1
- package/dist/features/orders/components/OrderProvenanceCard.d.ts +8 -0
- package/dist/features/orders/components/OrderProvenanceCard.js +185 -0
- package/dist/features/orders/components/RaiseDisputeForm.d.ts +14 -0
- package/dist/features/orders/components/RaiseDisputeForm.js +119 -0
- package/dist/features/orders/components/RefundRequestView.js +19 -1
- package/dist/features/orders/components/index.d.ts +4 -0
- package/dist/features/orders/components/index.js +2 -0
- package/dist/features/orders/constants/order-lane-tabs.d.ts +69 -0
- package/dist/features/orders/constants/order-lane-tabs.js +88 -0
- package/dist/features/orders/repository/orders.repository.d.ts +85 -1
- package/dist/features/orders/repository/orders.repository.js +205 -6
- package/dist/features/orders/schemas/balance.d.ts +40 -0
- package/dist/features/orders/schemas/balance.js +47 -0
- package/dist/features/orders/schemas/firestore.d.ts +398 -1
- package/dist/features/orders/schemas/firestore.js +43 -0
- package/dist/features/orders/schemas/index.d.ts +56 -8
- package/dist/features/orders/schemas/index.js +12 -0
- package/dist/features/orders/types/index.d.ts +47 -0
- package/dist/features/orders/utils/courier-block.d.ts +25 -0
- package/dist/features/orders/utils/courier-block.js +23 -0
- package/dist/features/payment-cases/schemas/firestore.d.ts +26 -1
- package/dist/features/payment-cases/schemas/firestore.js +1 -0
- package/dist/features/payments/repository/payout.repository.js +3 -2
- package/dist/features/payments/schemas/firestore.d.ts +92 -1
- package/dist/features/payments/schemas/firestore.js +23 -0
- package/dist/features/pricing/components/AdminPricingView.d.ts +13 -0
- package/dist/features/pricing/components/AdminPricingView.js +157 -0
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -2
- package/dist/features/products/components/ProductDetailPageView.d.ts +17 -1
- package/dist/features/products/components/ProductDetailPageView.js +2 -2
- package/dist/features/products/schemas/firestore.d.ts +26 -1
- package/dist/features/products/schemas/firestore.js +2 -0
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/products/schemas/product-features.validators.d.ts +10 -10
- package/dist/features/promotions/actions/coupon-stacking.d.ts +30 -3
- package/dist/features/promotions/actions/coupon-stacking.js +21 -2
- package/dist/features/promotions/components/CouponCard.js +13 -0
- package/dist/features/promotions/constants/coupon-help.d.ts +2 -2
- package/dist/features/promotions/constants/coupon-help.js +9 -1
- package/dist/features/promotions/schemas/firestore.d.ts +24 -0
- package/dist/features/promotions/schemas/firestore.js +4 -0
- package/dist/features/promotions/types/index.d.ts +19 -1
- package/dist/features/reviews/repository/reviews.repository.d.ts +14 -0
- package/dist/features/reviews/repository/reviews.repository.js +16 -0
- package/dist/features/scams/components/ScamProfileView.js +2 -2
- package/dist/features/scams/components/ScamRegistryView.js +2 -2
- package/dist/features/scams/repository/scammer.repository.js +2 -1
- package/dist/features/scams/schemas/firestore.d.ts +2 -1
- package/dist/features/scams/schemas/firestore.js +2 -1
- package/dist/features/search/action-index/tab-entries.js +1 -1
- package/dist/features/seller/actions/offer-actions.js +56 -0
- package/dist/features/seller/actions/seller-actions.d.ts +1 -1
- package/dist/features/seller/actions/seller-actions.js +37 -3
- package/dist/features/seller/components/DispatchForm.d.ts +11 -0
- package/dist/features/seller/components/DispatchForm.js +190 -0
- package/dist/features/seller/components/SellerOrdersView.js +221 -27
- package/dist/features/seller/components/index.d.ts +1 -0
- package/dist/features/seller/components/index.js +1 -0
- package/dist/features/seller/hooks/useSellerListingData.js +25 -0
- package/dist/features/seller/repository/offer.repository.js +4 -3
- package/dist/features/seller/schemas/firestore.d.ts +23 -2
- package/dist/features/seller/schemas/firestore.js +3 -2
- package/dist/features/seller/schemas/index.d.ts +4 -4
- package/dist/features/seller/schemas/order-forms.d.ts +61 -4
- package/dist/features/seller/schemas/order-forms.js +41 -8
- package/dist/features/shipments/schemas/firestore.d.ts +25 -0
- package/dist/features/shipments/schemas/validation.d.ts +4 -4
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +82 -0
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +145 -0
- package/dist/features/shipping-boxes/schemas/firestore.d.ts +80 -0
- package/dist/features/shipping-boxes/schemas/firestore.js +53 -0
- package/dist/features/store-extensions/schemas/report-create-form.d.ts +2 -2
- package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +2 -2
- package/dist/features/stores/components/StoreOrdersGuideView.js +6 -1
- package/dist/features/stores/repository/store.repository.d.ts +18 -0
- package/dist/features/stores/repository/store.repository.js +27 -1
- package/dist/features/stores/schemas/firestore.d.ts +112 -0
- package/dist/features/stores/schemas/firestore.js +20 -0
- package/dist/features/stores/schemas/index.d.ts +6 -6
- package/dist/features/stores/types/index.d.ts +19 -0
- package/dist/features/support/repository/support.repository.js +72 -6
- package/dist/features/support/schemas/firestore.d.ts +17 -0
- package/dist/features/support/schemas/index.d.ts +8 -8
- package/dist/features/wishlist/hooks/useWishlistWithGuest.d.ts +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +33 -1
- package/dist/next/routing/route-map.d.ts +112 -0
- package/dist/next/routing/route-map.js +63 -0
- package/dist/providers/shipping-shiprocket/index.d.ts +27 -1
- package/dist/providers/shipping-shiprocket/index.js +84 -1
- package/dist/react/contexts/SessionContext.d.ts +17 -1
- package/dist/schemas/registry.d.ts +69 -34
- package/dist/schemas/sieve.d.ts +2 -2
- package/dist/security/pii-schemas.d.ts +1 -1
- package/dist/security/pii-schemas.js +45 -0
- package/dist/server-entry.d.ts +2 -2
- package/dist/server-entry.js +8 -1
- package/dist/server.d.ts +36 -1
- package/dist/server.js +183 -1
- package/dist/styles.css +412 -80
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/default-dark.js +1 -0
- package/dist/tokens/themes/default-light.js +1 -0
- package/dist/tokens/themes/required.d.ts +1 -1
- package/dist/tokens/themes/required.js +1 -0
- package/dist/tokens/tokens.css +15 -0
- package/dist/ui/components/Button.d.ts +10 -1
- package/dist/ui/components/Button.js +25 -3
- package/dist/ui/components/Chart.d.ts +63 -0
- package/dist/ui/components/Chart.js +169 -0
- package/dist/ui/components/Chart.style.css +214 -0
- package/dist/ui/components/Disclaimer.d.ts +19 -0
- package/dist/ui/components/Disclaimer.js +83 -0
- package/dist/ui/components/OverflowRow.js +54 -2
- package/dist/ui/components/OverflowRow.style.css +38 -0
- package/dist/ui/components/RichTextEditor.js +99 -8
- package/dist/ui/components/chart-spec.d.ts +68 -0
- package/dist/ui/components/chart-spec.js +140 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/components/surface-tokens.d.ts +1 -1
- package/dist/ui/components/surface-tokens.js +9 -0
- package/dist/ui/components/tabs-overflow.js +64 -11
- package/dist/ui/components/toolbar-collapse.d.ts +13 -0
- package/dist/ui/components/toolbar-collapse.js +10 -1
- package/dist/ui/index.d.ts +8 -0
- package/dist/ui/index.js +9 -0
- package/dist/ui/rich-text/RichText.d.ts +10 -1
- package/dist/ui/rich-text/RichText.js +33 -2
- package/dist/ui/rich-text/RichText.style.css +83 -79
- package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
- package/dist/ui/rich-text/RichTextRenderer.js +34 -2
- package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
- package/dist/ui/rich-text/chart-blocks.js +87 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +1834 -1822
package/scripts/seed-cli.mjs
CHANGED
|
@@ -1,1822 +1,1834 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* seed-cli — standalone Firestore seed loader / deleter for LetItRip.
|
|
4
|
-
*
|
|
5
|
-
* Talks directly to Firestore via firebase-admin — does not require the Next.js
|
|
6
|
-
* dev server to be running. Reads Firebase credentials and PII secrets from the
|
|
7
|
-
* consumer project's `.env.local` (resolved against process.cwd()).
|
|
8
|
-
*
|
|
9
|
-
* This mirrors the logic in src/app/api/demo/seed/route.ts. Keep them in sync.
|
|
10
|
-
*
|
|
11
|
-
* Usage (from consumer project root, e.g. d:/proj/letitrip.in):
|
|
12
|
-
* npx appkit-seed load # load all collections
|
|
13
|
-
* npx appkit-seed load --collections users,categories
|
|
14
|
-
* npx appkit-seed delete --yes # purge all (skip confirmation)
|
|
15
|
-
* npx appkit-seed load --dry-run # plan without writing
|
|
16
|
-
* npx appkit-seed status # show seed/existing counts
|
|
17
|
-
*
|
|
18
|
-
* Flags:
|
|
19
|
-
* --collections <a,b,c> subset of collections (default: all)
|
|
20
|
-
* --dry-run compute plan only, no writes
|
|
21
|
-
* --yes skip the destructive-confirm prompt for `delete`
|
|
22
|
-
* --verbose log every doc id
|
|
23
|
-
* --service-account <p> path to a JSON key file (overrides env vars)
|
|
24
|
-
*
|
|
25
|
-
* Exit: 0 = success, 1 = errors during run, 2 = misconfiguration.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
-
import { resolve, basename, dirname } from "node:path";
|
|
30
|
-
import { createRequire, register } from "node:module";
|
|
31
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
32
|
-
import readline from "node:readline";
|
|
33
|
-
|
|
34
|
-
// Register an ESM resolver hook that retries with `.js` / `/index.js` when
|
|
35
|
-
// appkit's bundler-target dist emits extension-less relative imports.
|
|
36
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
-
register("./node-esm-loader.mjs", pathToFileURL(__dirname + "/"));
|
|
38
|
-
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// CLI parsing — entry point detects bin name so the same script powers
|
|
41
|
-
// `appkit-seed <verb>`, `appkit-seed-add`, and `appkit-seed-remove`.
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
const rawArgs = process.argv.slice(2);
|
|
44
|
-
const POSITIONAL = rawArgs.filter((a) => !a.startsWith("--"));
|
|
45
|
-
function flag(name, fallback = undefined) {
|
|
46
|
-
const i = rawArgs.indexOf(`--${name}`);
|
|
47
|
-
if (i === -1) return fallback;
|
|
48
|
-
const v = rawArgs[i + 1];
|
|
49
|
-
return v && !v.startsWith("--") ? v : true;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const binName = basename(process.argv[1] || "").replace(/\.(mjs|js|cmd|exe|ps1)$/i, "");
|
|
53
|
-
const BIN_ACTION_MAP = {
|
|
54
|
-
"appkit-seed-add": "load",
|
|
55
|
-
"appkit-seed-remove": "delete",
|
|
56
|
-
"appkit-seed-status": "status",
|
|
57
|
-
};
|
|
58
|
-
const ACTION = BIN_ACTION_MAP[binName] ?? POSITIONAL[0];
|
|
59
|
-
if (!["load", "delete", "status", "purge"].includes(ACTION)) {
|
|
60
|
-
console.error("usage:");
|
|
61
|
-
console.error(" appkit-seed <load|delete|status|purge> [flags]");
|
|
62
|
-
console.error(" appkit-seed-add [flags] # alias for `appkit-seed load`");
|
|
63
|
-
console.error(" appkit-seed-remove [flags] # alias for `appkit-seed delete`");
|
|
64
|
-
console.error("");
|
|
65
|
-
console.error(" purge delete every document whose seedPurpose is \"test\", and NOTHING else.");
|
|
66
|
-
console.error(" Unlike `delete`, which empties whole collections, this keeps real");
|
|
67
|
-
console.error(" content (faqs, categories, taxCodes, productFeatures, blogPosts,");
|
|
68
|
-
console.error(" carousels, homepageSections, siteSettings, the tester checklist) and");
|
|
69
|
-
console.error(" keeps the two keep-list rows: the admin account and store-letitrip-official.");
|
|
70
|
-
console.error(" Organic rows carry no seedPurpose at all and are unreachable by it.");
|
|
71
|
-
console.error("");
|
|
72
|
-
console.error("flags: --collections a,b --dry-run --yes --verbose --service-account <path>");
|
|
73
|
-
process.exit(2);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const ONLY = flag("collections");
|
|
77
|
-
const DRY_RUN = flag("dry-run") === true;
|
|
78
|
-
const SKIP_CONFIRM = flag("yes") === true;
|
|
79
|
-
const VERBOSE = flag("verbose") === true;
|
|
80
|
-
const SERVICE_ACCOUNT_OVERRIDE = typeof flag("service-account") === "string" ? flag("service-account") : null;
|
|
81
|
-
|
|
82
|
-
const ONLY_SET = ONLY && typeof ONLY === "string"
|
|
83
|
-
? new Set(ONLY.split(",").map((s) => s.trim()))
|
|
84
|
-
: null;
|
|
85
|
-
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
// .env.local loader (avoids a dotenv dep, same shape as firebase-reset.mjs)
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
const repoRoot = process.cwd();
|
|
90
|
-
const envLocalPath = resolve(repoRoot, ".env.local");
|
|
91
|
-
if (existsSync(envLocalPath)) {
|
|
92
|
-
for (const line of readFileSync(envLocalPath, "utf8").split("\n")) {
|
|
93
|
-
const t = line.trim();
|
|
94
|
-
if (!t || t.startsWith("#")) continue;
|
|
95
|
-
const eq = t.indexOf("=");
|
|
96
|
-
if (eq === -1) continue;
|
|
97
|
-
const k = t.slice(0, eq).trim();
|
|
98
|
-
const raw = t.slice(eq + 1).trim();
|
|
99
|
-
const v = raw.replace(/^["']|["']$/g, "");
|
|
100
|
-
if (!(k in process.env)) process.env[k] = v;
|
|
101
|
-
}
|
|
102
|
-
if (VERBOSE) console.log(`✓ Loaded ${envLocalPath}`);
|
|
103
|
-
} else {
|
|
104
|
-
console.warn(`⚠ .env.local not found at ${envLocalPath} — relying on existing process.env`);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Validate config
|
|
108
|
-
const required = ["FIREBASE_ADMIN_PROJECT_ID", "FIREBASE_ADMIN_CLIENT_EMAIL", "FIREBASE_ADMIN_PRIVATE_KEY"];
|
|
109
|
-
const missing = required.filter((k) => !process.env[k]);
|
|
110
|
-
if (!SERVICE_ACCOUNT_OVERRIDE && missing.length > 0) {
|
|
111
|
-
console.error(`✗ Missing required env vars: ${missing.join(", ")}`);
|
|
112
|
-
console.error(" Either set them in .env.local or pass --service-account <path-to-key.json>");
|
|
113
|
-
process.exit(2);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// PII keys are mandatory for collections that encrypt fields
|
|
117
|
-
const PII_REQUIRED = ["users", "addresses", "storeAddresses", "products", "orders", "reviews", "bids", "payouts", "eventEntries"];
|
|
118
|
-
// PII_ENCRYPTION_KEY ONLY. `PII_SECRET` used to be accepted here as an
|
|
119
|
-
// alternative and is read by no runtime code — so a machine with only that set
|
|
120
|
-
// passed this check and then threw at the first encrypt, mid-load, with some
|
|
121
|
-
// collections already written.
|
|
122
|
-
const PII_KEY_PRESENT = Boolean(process.env.PII_ENCRYPTION_KEY);
|
|
123
|
-
if (ACTION === "load" && !PII_KEY_PRESENT) {
|
|
124
|
-
// A hard stop, not a warning. Loading without the key does not skip the PII
|
|
125
|
-
// collections — it writes some and then throws, leaving a half-seeded
|
|
126
|
-
// database that looks like a successful run until someone reads it.
|
|
127
|
-
console.error("✖ PII_ENCRYPTION_KEY is not set — users, addresses, orders, reviews, bids, payouts and eventEntries all encrypt fields at write time.");
|
|
128
|
-
process.exit(2);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// firebase-admin init
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
const require = createRequire(import.meta.url);
|
|
135
|
-
// appkit's pii-encrypt.ts calls a bare `require("crypto")` at runtime (not a
|
|
136
|
-
// static import — deliberately, so bundlers like Turbopack never see a
|
|
137
|
-
// node:module dependency and refuse to build the client bundle). A bare
|
|
138
|
-
// `require` identifier resolves via the global scope in Node ESM, so it must
|
|
139
|
-
// exist as a global before the dynamic `import("@mohasinac/appkit")` below
|
|
140
|
-
// ever triggers that code path. Only this standalone-script environment
|
|
141
|
-
// needs it — Next.js's own server bundle (webpack/Turbopack) provides an
|
|
142
|
-
// ambient `require` automatically.
|
|
143
|
-
globalThis.require = require;
|
|
144
|
-
const admin = require("firebase-admin");
|
|
145
|
-
|
|
146
|
-
function parsePrivateKey(raw) {
|
|
147
|
-
if (!raw) return raw;
|
|
148
|
-
return raw.includes("\\n") ? raw.replace(/\\n/g, "\n") : raw;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (!admin.apps.length) {
|
|
152
|
-
if (SERVICE_ACCOUNT_OVERRIDE) {
|
|
153
|
-
const key = JSON.parse(readFileSync(resolve(repoRoot, SERVICE_ACCOUNT_OVERRIDE), "utf8"));
|
|
154
|
-
admin.initializeApp({
|
|
155
|
-
credential: admin.credential.cert(key),
|
|
156
|
-
databaseURL: process.env.FIREBASE_ADMIN_DATABASE_URL || process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
|
|
157
|
-
});
|
|
158
|
-
} else {
|
|
159
|
-
admin.initializeApp({
|
|
160
|
-
credential: admin.credential.cert({
|
|
161
|
-
projectId: process.env.FIREBASE_ADMIN_PROJECT_ID.trim(),
|
|
162
|
-
clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL.trim(),
|
|
163
|
-
privateKey: parsePrivateKey(process.env.FIREBASE_ADMIN_PRIVATE_KEY),
|
|
164
|
-
}),
|
|
165
|
-
databaseURL:
|
|
166
|
-
process.env.FIREBASE_ADMIN_DATABASE_URL ||
|
|
167
|
-
process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
const db = admin.firestore();
|
|
172
|
-
const auth = admin.auth();
|
|
173
|
-
console.log(`✓ Connected to project: ${process.env.FIREBASE_ADMIN_PROJECT_ID || "(service-account)"}`);
|
|
174
|
-
|
|
175
|
-
// ---------------------------------------------------------------------------
|
|
176
|
-
// Pull seed data + helpers from @mohasinac/appkit
|
|
177
|
-
// ---------------------------------------------------------------------------
|
|
178
|
-
/*
|
|
179
|
-
* ── 🛑 SEED DATA COMES FROM ./seed, NOT THE MAIN BARREL ──────────────────
|
|
180
|
-
*
|
|
181
|
-
* `appkit/src/seed/` is 61% of this package's source bytes and 60% of the
|
|
182
|
-
* published dist — 540 files of pure fixture data. While the main barrel
|
|
183
|
-
* re-exported it, every consumer importing `@mohasinac/appkit` pulled that
|
|
184
|
-
* graph into scope and Turbopack traced all 24 MB of it for nothing.
|
|
185
|
-
*
|
|
186
|
-
* The re-exports are gone from `index.ts`/`server.ts`, so this CLI — the
|
|
187
|
-
* only real consumer of the data — reads it from a dedicated subpath.
|
|
188
|
-
*
|
|
189
|
-
* 🛑 Two imports, deliberately. The seed ARRAYS come from ./seed; the
|
|
190
|
-
* HELPERS (id generators, token builders, PII field maps) stay on the main
|
|
191
|
-
* barrel because they are real library code that the app uses too. Merging
|
|
192
|
-
* them would pull the helpers back into the seed graph and undo the split.
|
|
193
|
-
*/
|
|
194
|
-
/*
|
|
195
|
-
* ── 🛑 TWO PLACES THE SEED CAN LIVE, AND A CLEAR ERROR IF NEITHER ────────
|
|
196
|
-
*
|
|
197
|
-
* `dist/seed/**` is 27 MB of the 52 MB dist and is EXCLUDED from the
|
|
198
|
-
* published tarball — a package consumer has no business carrying fixture
|
|
199
|
-
* data. So the `./seed` subpath resolves only where the data actually is:
|
|
200
|
-
*
|
|
201
|
-
* · an installed copy that was resynced from this repo (the normal local
|
|
202
|
-
* case — `cp -r appkit/dist node_modules/@mohasinac/appkit/dist`)
|
|
203
|
-
* · this repo's own `appkit/dist/seed`, when run from the source tree
|
|
204
|
-
*
|
|
205
|
-
* 🛑 A fresh `npm install` from the registry has NEITHER, and that is
|
|
206
|
-
* deliberate: seeding needs the generators and the 18 MB of research JSON
|
|
207
|
-
* that only exist in this repo, so it is a repo operation by nature. What
|
|
208
|
-
* must not happen is the SILENT version — an import that resolves to nothing
|
|
209
|
-
* and a loader that then reports `no seed data, skipping` with exit 0, which
|
|
210
|
-
* is Root Cause #90 and has already cost this project six empty collections.
|
|
211
|
-
* Hence an explicit throw naming both paths.
|
|
212
|
-
*/
|
|
213
|
-
async function importSeedModule() {
|
|
214
|
-
const attempts = [];
|
|
215
|
-
/*
|
|
216
|
-
* 🛑 A RELATIVE path, not the `@mohasinac/appkit/seed` subpath.
|
|
217
|
-
*
|
|
218
|
-
* That subpath was added and then REMOVED in the same change: once
|
|
219
|
-
* `dist/seed` is excluded from the tarball, a declared export pointing at a
|
|
220
|
-
* file the package does not contain is a landmine for anyone who imports it.
|
|
221
|
-
* Attempting it here would also be dead code — it can never resolve now — and
|
|
222
|
-
* a dead attempt whose throw is swallowed is exactly what makes the next
|
|
223
|
-
* reader think the subpath still works.
|
|
224
|
-
*
|
|
225
|
-
* Relative resolution covers both real cases: run from this repo it finds
|
|
226
|
-
* `appkit/dist/seed`; run as an installed copy that was resynced from the
|
|
227
|
-
* repo it finds `node_modules/@mohasinac/appkit/dist/seed`.
|
|
228
|
-
*/
|
|
229
|
-
const local = pathToFileURL(resolve(__dirname, "../dist/seed/index.js")).href;
|
|
230
|
-
try {
|
|
231
|
-
return await import(local);
|
|
232
|
-
} catch (err) {
|
|
233
|
-
attempts.push(`${local} — ${err?.code ?? err?.message ?? err}`);
|
|
234
|
-
}
|
|
235
|
-
console.error(
|
|
236
|
-
"\u001b[31m✗ Could not load the seed data.\u001b[0m\n" +
|
|
237
|
-
" The seed is excluded from the published package on purpose — it is a repo\n" +
|
|
238
|
-
" operation, not something a consumer does. Build it first:\n\n" +
|
|
239
|
-
" npm --prefix ./appkit run build\n\n" +
|
|
240
|
-
" Tried:\n" +
|
|
241
|
-
attempts.map((a) => ` ${a}`).join("\n"),
|
|
242
|
-
);
|
|
243
|
-
process.exit(1);
|
|
244
|
-
}
|
|
245
|
-
const appkitSeed = await importSeedModule();
|
|
246
|
-
const appkit = { ...(await import("@mohasinac/appkit")), ...appkitSeed };
|
|
247
|
-
// PII crypto functions moved server-only in appkit 3.8.1 (they touch
|
|
248
|
-
// firebase-admin's crypto chain) — no longer exported from the main
|
|
249
|
-
// client-safe barrel, only from "@mohasinac/appkit/server". The *_PII_FIELDS
|
|
250
|
-
// / *_PII_INDEX_MAP constants are plain data and stay in the main barrel.
|
|
251
|
-
const appkitServer = await import("@mohasinac/appkit/server");
|
|
252
|
-
const { encryptPiiFields, piiIndicesFor, encryptPayoutDetails, encryptPayoutBankAccount, encryptShippingConfig,
|
|
253
|
-
buildTokensIndex, BLIND_INDEX_KEY_VERSION_FIELD, CURRENT_BLIND_INDEX_KEY_VERSION } = appkitServer;
|
|
254
|
-
|
|
255
|
-
const {
|
|
256
|
-
// seed data
|
|
257
|
-
usersSeedData, addressesSeedData, storeAddressesSeedData,
|
|
258
|
-
// store-extensions — see the SEED_DATA_MAP note below (Root Cause #90, 7-16).
|
|
259
|
-
payoutMethodsSeedData, shippingConfigsSeedData, analyticsCardsSeedData, analyticsAlertsSeedData,
|
|
260
|
-
storeCategoriesSeedData, listingTemplatesSeedData, moderationQueueSeedData, reportsSeedData,
|
|
261
|
-
itemRequestsSeedData, storeGoogleConfigSeedData,
|
|
262
|
-
categoriesSeedData, storesSeedData, sessionsSeedData,
|
|
263
|
-
productsStandardSeedData, productsBeybladeXSeedData, productsAuctionsSeedData, productsPreordersSeedData,
|
|
264
|
-
productsPrizeDrawsSeedData, productsClassifiedsSeedData, productsDigitalCodesSeedData, productsLiveItemsSeedData,
|
|
265
|
-
productsArtSeedData, productsStickersSeedData, productsSparesSeedData,
|
|
266
|
-
productsCatalogueSeedData,
|
|
267
|
-
FRANCHISE_BY_CATEGORY_ROOT,
|
|
268
|
-
ordersSeedData, reviewsSeedData, cartsSeedData, bidsSeedData,
|
|
269
|
-
couponsSeedData, couponUsageSeedData,
|
|
270
|
-
eventsSeedData, eventEntriesSeedData, payoutsSeedData, offersSeedData,
|
|
271
|
-
notificationsSeedData, blogPostsSeedData,
|
|
272
|
-
carouselsSeedData, carouselSlidesSeedData, homepageSectionsSeedData,
|
|
273
|
-
siteSettingsSeedData, faqSeedData,
|
|
274
|
-
wishlistsSeedData, historySeedData,
|
|
275
|
-
groupedListingsSeedData,
|
|
276
|
-
mediaAssetsSeedData, mediaAssetsEditorialSeedData,
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
*
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
*
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
/*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* ──
|
|
490
|
-
*
|
|
491
|
-
* `
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
...(
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
* `
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
* `
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
*
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
const
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
/**
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
)
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
console.log(
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
*
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
*
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
)
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
}
|
|
1599
|
-
} else if (colName === "
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
const
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
console.log(
|
|
1637
|
-
console.log(
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
1667
|
-
*
|
|
1668
|
-
*
|
|
1669
|
-
*
|
|
1670
|
-
*
|
|
1671
|
-
*
|
|
1672
|
-
*
|
|
1673
|
-
*
|
|
1674
|
-
*
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
)
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
if (
|
|
1821
|
-
|
|
1822
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* seed-cli — standalone Firestore seed loader / deleter for LetItRip.
|
|
4
|
+
*
|
|
5
|
+
* Talks directly to Firestore via firebase-admin — does not require the Next.js
|
|
6
|
+
* dev server to be running. Reads Firebase credentials and PII secrets from the
|
|
7
|
+
* consumer project's `.env.local` (resolved against process.cwd()).
|
|
8
|
+
*
|
|
9
|
+
* This mirrors the logic in src/app/api/demo/seed/route.ts. Keep them in sync.
|
|
10
|
+
*
|
|
11
|
+
* Usage (from consumer project root, e.g. d:/proj/letitrip.in):
|
|
12
|
+
* npx appkit-seed load # load all collections
|
|
13
|
+
* npx appkit-seed load --collections users,categories
|
|
14
|
+
* npx appkit-seed delete --yes # purge all (skip confirmation)
|
|
15
|
+
* npx appkit-seed load --dry-run # plan without writing
|
|
16
|
+
* npx appkit-seed status # show seed/existing counts
|
|
17
|
+
*
|
|
18
|
+
* Flags:
|
|
19
|
+
* --collections <a,b,c> subset of collections (default: all)
|
|
20
|
+
* --dry-run compute plan only, no writes
|
|
21
|
+
* --yes skip the destructive-confirm prompt for `delete`
|
|
22
|
+
* --verbose log every doc id
|
|
23
|
+
* --service-account <p> path to a JSON key file (overrides env vars)
|
|
24
|
+
*
|
|
25
|
+
* Exit: 0 = success, 1 = errors during run, 2 = misconfiguration.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import { resolve, basename, dirname } from "node:path";
|
|
30
|
+
import { createRequire, register } from "node:module";
|
|
31
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
32
|
+
import readline from "node:readline";
|
|
33
|
+
|
|
34
|
+
// Register an ESM resolver hook that retries with `.js` / `/index.js` when
|
|
35
|
+
// appkit's bundler-target dist emits extension-less relative imports.
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
register("./node-esm-loader.mjs", pathToFileURL(__dirname + "/"));
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// CLI parsing — entry point detects bin name so the same script powers
|
|
41
|
+
// `appkit-seed <verb>`, `appkit-seed-add`, and `appkit-seed-remove`.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const rawArgs = process.argv.slice(2);
|
|
44
|
+
const POSITIONAL = rawArgs.filter((a) => !a.startsWith("--"));
|
|
45
|
+
function flag(name, fallback = undefined) {
|
|
46
|
+
const i = rawArgs.indexOf(`--${name}`);
|
|
47
|
+
if (i === -1) return fallback;
|
|
48
|
+
const v = rawArgs[i + 1];
|
|
49
|
+
return v && !v.startsWith("--") ? v : true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const binName = basename(process.argv[1] || "").replace(/\.(mjs|js|cmd|exe|ps1)$/i, "");
|
|
53
|
+
const BIN_ACTION_MAP = {
|
|
54
|
+
"appkit-seed-add": "load",
|
|
55
|
+
"appkit-seed-remove": "delete",
|
|
56
|
+
"appkit-seed-status": "status",
|
|
57
|
+
};
|
|
58
|
+
const ACTION = BIN_ACTION_MAP[binName] ?? POSITIONAL[0];
|
|
59
|
+
if (!["load", "delete", "status", "purge"].includes(ACTION)) {
|
|
60
|
+
console.error("usage:");
|
|
61
|
+
console.error(" appkit-seed <load|delete|status|purge> [flags]");
|
|
62
|
+
console.error(" appkit-seed-add [flags] # alias for `appkit-seed load`");
|
|
63
|
+
console.error(" appkit-seed-remove [flags] # alias for `appkit-seed delete`");
|
|
64
|
+
console.error("");
|
|
65
|
+
console.error(" purge delete every document whose seedPurpose is \"test\", and NOTHING else.");
|
|
66
|
+
console.error(" Unlike `delete`, which empties whole collections, this keeps real");
|
|
67
|
+
console.error(" content (faqs, categories, taxCodes, productFeatures, blogPosts,");
|
|
68
|
+
console.error(" carousels, homepageSections, siteSettings, the tester checklist) and");
|
|
69
|
+
console.error(" keeps the two keep-list rows: the admin account and store-letitrip-official.");
|
|
70
|
+
console.error(" Organic rows carry no seedPurpose at all and are unreachable by it.");
|
|
71
|
+
console.error("");
|
|
72
|
+
console.error("flags: --collections a,b --dry-run --yes --verbose --service-account <path>");
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ONLY = flag("collections");
|
|
77
|
+
const DRY_RUN = flag("dry-run") === true;
|
|
78
|
+
const SKIP_CONFIRM = flag("yes") === true;
|
|
79
|
+
const VERBOSE = flag("verbose") === true;
|
|
80
|
+
const SERVICE_ACCOUNT_OVERRIDE = typeof flag("service-account") === "string" ? flag("service-account") : null;
|
|
81
|
+
|
|
82
|
+
const ONLY_SET = ONLY && typeof ONLY === "string"
|
|
83
|
+
? new Set(ONLY.split(",").map((s) => s.trim()))
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// .env.local loader (avoids a dotenv dep, same shape as firebase-reset.mjs)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
const repoRoot = process.cwd();
|
|
90
|
+
const envLocalPath = resolve(repoRoot, ".env.local");
|
|
91
|
+
if (existsSync(envLocalPath)) {
|
|
92
|
+
for (const line of readFileSync(envLocalPath, "utf8").split("\n")) {
|
|
93
|
+
const t = line.trim();
|
|
94
|
+
if (!t || t.startsWith("#")) continue;
|
|
95
|
+
const eq = t.indexOf("=");
|
|
96
|
+
if (eq === -1) continue;
|
|
97
|
+
const k = t.slice(0, eq).trim();
|
|
98
|
+
const raw = t.slice(eq + 1).trim();
|
|
99
|
+
const v = raw.replace(/^["']|["']$/g, "");
|
|
100
|
+
if (!(k in process.env)) process.env[k] = v;
|
|
101
|
+
}
|
|
102
|
+
if (VERBOSE) console.log(`✓ Loaded ${envLocalPath}`);
|
|
103
|
+
} else {
|
|
104
|
+
console.warn(`⚠ .env.local not found at ${envLocalPath} — relying on existing process.env`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Validate config
|
|
108
|
+
const required = ["FIREBASE_ADMIN_PROJECT_ID", "FIREBASE_ADMIN_CLIENT_EMAIL", "FIREBASE_ADMIN_PRIVATE_KEY"];
|
|
109
|
+
const missing = required.filter((k) => !process.env[k]);
|
|
110
|
+
if (!SERVICE_ACCOUNT_OVERRIDE && missing.length > 0) {
|
|
111
|
+
console.error(`✗ Missing required env vars: ${missing.join(", ")}`);
|
|
112
|
+
console.error(" Either set them in .env.local or pass --service-account <path-to-key.json>");
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// PII keys are mandatory for collections that encrypt fields
|
|
117
|
+
const PII_REQUIRED = ["users", "addresses", "storeAddresses", "products", "orders", "reviews", "bids", "payouts", "eventEntries"];
|
|
118
|
+
// PII_ENCRYPTION_KEY ONLY. `PII_SECRET` used to be accepted here as an
|
|
119
|
+
// alternative and is read by no runtime code — so a machine with only that set
|
|
120
|
+
// passed this check and then threw at the first encrypt, mid-load, with some
|
|
121
|
+
// collections already written.
|
|
122
|
+
const PII_KEY_PRESENT = Boolean(process.env.PII_ENCRYPTION_KEY);
|
|
123
|
+
if (ACTION === "load" && !PII_KEY_PRESENT) {
|
|
124
|
+
// A hard stop, not a warning. Loading without the key does not skip the PII
|
|
125
|
+
// collections — it writes some and then throws, leaving a half-seeded
|
|
126
|
+
// database that looks like a successful run until someone reads it.
|
|
127
|
+
console.error("✖ PII_ENCRYPTION_KEY is not set — users, addresses, orders, reviews, bids, payouts and eventEntries all encrypt fields at write time.");
|
|
128
|
+
process.exit(2);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// firebase-admin init
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
const require = createRequire(import.meta.url);
|
|
135
|
+
// appkit's pii-encrypt.ts calls a bare `require("crypto")` at runtime (not a
|
|
136
|
+
// static import — deliberately, so bundlers like Turbopack never see a
|
|
137
|
+
// node:module dependency and refuse to build the client bundle). A bare
|
|
138
|
+
// `require` identifier resolves via the global scope in Node ESM, so it must
|
|
139
|
+
// exist as a global before the dynamic `import("@mohasinac/appkit")` below
|
|
140
|
+
// ever triggers that code path. Only this standalone-script environment
|
|
141
|
+
// needs it — Next.js's own server bundle (webpack/Turbopack) provides an
|
|
142
|
+
// ambient `require` automatically.
|
|
143
|
+
globalThis.require = require;
|
|
144
|
+
const admin = require("firebase-admin");
|
|
145
|
+
|
|
146
|
+
function parsePrivateKey(raw) {
|
|
147
|
+
if (!raw) return raw;
|
|
148
|
+
return raw.includes("\\n") ? raw.replace(/\\n/g, "\n") : raw;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!admin.apps.length) {
|
|
152
|
+
if (SERVICE_ACCOUNT_OVERRIDE) {
|
|
153
|
+
const key = JSON.parse(readFileSync(resolve(repoRoot, SERVICE_ACCOUNT_OVERRIDE), "utf8"));
|
|
154
|
+
admin.initializeApp({
|
|
155
|
+
credential: admin.credential.cert(key),
|
|
156
|
+
databaseURL: process.env.FIREBASE_ADMIN_DATABASE_URL || process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
admin.initializeApp({
|
|
160
|
+
credential: admin.credential.cert({
|
|
161
|
+
projectId: process.env.FIREBASE_ADMIN_PROJECT_ID.trim(),
|
|
162
|
+
clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL.trim(),
|
|
163
|
+
privateKey: parsePrivateKey(process.env.FIREBASE_ADMIN_PRIVATE_KEY),
|
|
164
|
+
}),
|
|
165
|
+
databaseURL:
|
|
166
|
+
process.env.FIREBASE_ADMIN_DATABASE_URL ||
|
|
167
|
+
process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const db = admin.firestore();
|
|
172
|
+
const auth = admin.auth();
|
|
173
|
+
console.log(`✓ Connected to project: ${process.env.FIREBASE_ADMIN_PROJECT_ID || "(service-account)"}`);
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Pull seed data + helpers from @mohasinac/appkit
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
/*
|
|
179
|
+
* ── 🛑 SEED DATA COMES FROM ./seed, NOT THE MAIN BARREL ──────────────────
|
|
180
|
+
*
|
|
181
|
+
* `appkit/src/seed/` is 61% of this package's source bytes and 60% of the
|
|
182
|
+
* published dist — 540 files of pure fixture data. While the main barrel
|
|
183
|
+
* re-exported it, every consumer importing `@mohasinac/appkit` pulled that
|
|
184
|
+
* graph into scope and Turbopack traced all 24 MB of it for nothing.
|
|
185
|
+
*
|
|
186
|
+
* The re-exports are gone from `index.ts`/`server.ts`, so this CLI — the
|
|
187
|
+
* only real consumer of the data — reads it from a dedicated subpath.
|
|
188
|
+
*
|
|
189
|
+
* 🛑 Two imports, deliberately. The seed ARRAYS come from ./seed; the
|
|
190
|
+
* HELPERS (id generators, token builders, PII field maps) stay on the main
|
|
191
|
+
* barrel because they are real library code that the app uses too. Merging
|
|
192
|
+
* them would pull the helpers back into the seed graph and undo the split.
|
|
193
|
+
*/
|
|
194
|
+
/*
|
|
195
|
+
* ── 🛑 TWO PLACES THE SEED CAN LIVE, AND A CLEAR ERROR IF NEITHER ────────
|
|
196
|
+
*
|
|
197
|
+
* `dist/seed/**` is 27 MB of the 52 MB dist and is EXCLUDED from the
|
|
198
|
+
* published tarball — a package consumer has no business carrying fixture
|
|
199
|
+
* data. So the `./seed` subpath resolves only where the data actually is:
|
|
200
|
+
*
|
|
201
|
+
* · an installed copy that was resynced from this repo (the normal local
|
|
202
|
+
* case — `cp -r appkit/dist node_modules/@mohasinac/appkit/dist`)
|
|
203
|
+
* · this repo's own `appkit/dist/seed`, when run from the source tree
|
|
204
|
+
*
|
|
205
|
+
* 🛑 A fresh `npm install` from the registry has NEITHER, and that is
|
|
206
|
+
* deliberate: seeding needs the generators and the 18 MB of research JSON
|
|
207
|
+
* that only exist in this repo, so it is a repo operation by nature. What
|
|
208
|
+
* must not happen is the SILENT version — an import that resolves to nothing
|
|
209
|
+
* and a loader that then reports `no seed data, skipping` with exit 0, which
|
|
210
|
+
* is Root Cause #90 and has already cost this project six empty collections.
|
|
211
|
+
* Hence an explicit throw naming both paths.
|
|
212
|
+
*/
|
|
213
|
+
async function importSeedModule() {
|
|
214
|
+
const attempts = [];
|
|
215
|
+
/*
|
|
216
|
+
* 🛑 A RELATIVE path, not the `@mohasinac/appkit/seed` subpath.
|
|
217
|
+
*
|
|
218
|
+
* That subpath was added and then REMOVED in the same change: once
|
|
219
|
+
* `dist/seed` is excluded from the tarball, a declared export pointing at a
|
|
220
|
+
* file the package does not contain is a landmine for anyone who imports it.
|
|
221
|
+
* Attempting it here would also be dead code — it can never resolve now — and
|
|
222
|
+
* a dead attempt whose throw is swallowed is exactly what makes the next
|
|
223
|
+
* reader think the subpath still works.
|
|
224
|
+
*
|
|
225
|
+
* Relative resolution covers both real cases: run from this repo it finds
|
|
226
|
+
* `appkit/dist/seed`; run as an installed copy that was resynced from the
|
|
227
|
+
* repo it finds `node_modules/@mohasinac/appkit/dist/seed`.
|
|
228
|
+
*/
|
|
229
|
+
const local = pathToFileURL(resolve(__dirname, "../dist/seed/index.js")).href;
|
|
230
|
+
try {
|
|
231
|
+
return await import(local);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
attempts.push(`${local} — ${err?.code ?? err?.message ?? err}`);
|
|
234
|
+
}
|
|
235
|
+
console.error(
|
|
236
|
+
"\u001b[31m✗ Could not load the seed data.\u001b[0m\n" +
|
|
237
|
+
" The seed is excluded from the published package on purpose — it is a repo\n" +
|
|
238
|
+
" operation, not something a consumer does. Build it first:\n\n" +
|
|
239
|
+
" npm --prefix ./appkit run build\n\n" +
|
|
240
|
+
" Tried:\n" +
|
|
241
|
+
attempts.map((a) => ` ${a}`).join("\n"),
|
|
242
|
+
);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
const appkitSeed = await importSeedModule();
|
|
246
|
+
const appkit = { ...(await import("@mohasinac/appkit")), ...appkitSeed };
|
|
247
|
+
// PII crypto functions moved server-only in appkit 3.8.1 (they touch
|
|
248
|
+
// firebase-admin's crypto chain) — no longer exported from the main
|
|
249
|
+
// client-safe barrel, only from "@mohasinac/appkit/server". The *_PII_FIELDS
|
|
250
|
+
// / *_PII_INDEX_MAP constants are plain data and stay in the main barrel.
|
|
251
|
+
const appkitServer = await import("@mohasinac/appkit/server");
|
|
252
|
+
const { encryptPiiFields, piiIndicesFor, encryptPayoutDetails, encryptPayoutBankAccount, encryptShippingConfig,
|
|
253
|
+
buildTokensIndex, BLIND_INDEX_KEY_VERSION_FIELD, CURRENT_BLIND_INDEX_KEY_VERSION } = appkitServer;
|
|
254
|
+
|
|
255
|
+
const {
|
|
256
|
+
// seed data
|
|
257
|
+
usersSeedData, addressesSeedData, storeAddressesSeedData,
|
|
258
|
+
// store-extensions — see the SEED_DATA_MAP note below (Root Cause #90, 7-16).
|
|
259
|
+
payoutMethodsSeedData, shippingConfigsSeedData, analyticsCardsSeedData, analyticsAlertsSeedData,
|
|
260
|
+
storeCategoriesSeedData, listingTemplatesSeedData, moderationQueueSeedData, reportsSeedData,
|
|
261
|
+
itemRequestsSeedData, storeGoogleConfigSeedData,
|
|
262
|
+
categoriesSeedData, storesSeedData, sessionsSeedData,
|
|
263
|
+
productsStandardSeedData, productsBeybladeXSeedData, productsAuctionsSeedData, productsPreordersSeedData,
|
|
264
|
+
productsPrizeDrawsSeedData, productsClassifiedsSeedData, productsDigitalCodesSeedData, productsLiveItemsSeedData,
|
|
265
|
+
productsArtSeedData, productsStickersSeedData, productsSparesSeedData,
|
|
266
|
+
productsCatalogueSeedData,
|
|
267
|
+
FRANCHISE_BY_CATEGORY_ROOT,
|
|
268
|
+
ordersSeedData, reviewsSeedData, cartsSeedData, bidsSeedData,
|
|
269
|
+
couponsSeedData, couponUsageSeedData,
|
|
270
|
+
eventsSeedData, eventEntriesSeedData, payoutsSeedData, offersSeedData,
|
|
271
|
+
notificationsSeedData, blogPostsSeedData,
|
|
272
|
+
carouselsSeedData, carouselSlidesSeedData, homepageSectionsSeedData,
|
|
273
|
+
siteSettingsSeedData, faqSeedData,
|
|
274
|
+
wishlistsSeedData, historySeedData,
|
|
275
|
+
groupedListingsSeedData,
|
|
276
|
+
mediaAssetsSeedData, mediaAssetsEditorialSeedData,
|
|
277
|
+
chartOfAccountsSeedData,
|
|
278
|
+
scammersSeedData, productFeaturesSeedData, taxCodesSeedData,
|
|
279
|
+
disputeReasonsSeedData,
|
|
280
|
+
authenticityKnowledgeSeedData,
|
|
281
|
+
partGlossarySeedData, modelCompositionSeedData,
|
|
282
|
+
beysSeedData, beyProductsSeedData,
|
|
283
|
+
BEYS_COLLECTION, BEY_PRODUCTS_COLLECTION,
|
|
284
|
+
supportTicketsSeedData, catalogueSeedData,
|
|
285
|
+
shipmentsSeedData, shipmentLotsSeedData, shipmentItemsSeedData,
|
|
286
|
+
// collection constants
|
|
287
|
+
USER_COLLECTION,
|
|
288
|
+
CATEGORIES_COLLECTION, STORE_COLLECTION,
|
|
289
|
+
PRODUCT_COLLECTION, ORDER_COLLECTION, REVIEW_COLLECTION, BID_COLLECTION,
|
|
290
|
+
COUPONS_COLLECTION, CAROUSELS_COLLECTION, CAROUSEL_SLIDES_COLLECTION,
|
|
291
|
+
HOMEPAGE_SECTIONS_COLLECTION, SITE_SETTINGS_COLLECTION, FAQS_COLLECTION,
|
|
292
|
+
NOTIFICATIONS_COLLECTION, PAYOUT_COLLECTION, OFFER_COLLECTION, BLOG_POSTS_COLLECTION,
|
|
293
|
+
EVENTS_COLLECTION, EVENT_ENTRIES_COLLECTION, SESSION_COLLECTION,
|
|
294
|
+
CART_COLLECTION,
|
|
295
|
+
GROUPED_LISTINGS_COLLECTION, SCAMMER_COLLECTION,
|
|
296
|
+
WISHLIST_COLLECTION, HISTORY_COLLECTION, PRODUCT_FEATURES_COLLECTION,
|
|
297
|
+
TAX_CODES_COLLECTION,
|
|
298
|
+
DISPUTE_REASON_COLLECTION,
|
|
299
|
+
PART_GLOSSARY_COLLECTION,
|
|
300
|
+
MODEL_COMPOSITION_COLLECTION,
|
|
301
|
+
TESTER_CHECKLIST_ITEM_COLLECTION,
|
|
302
|
+
TESTER_CHECKLIST_RESPONSE_COLLECTION,
|
|
303
|
+
// PII field/index constants (plain data, still in the main barrel — the
|
|
304
|
+
// crypto functions themselves come from appkitServer above)
|
|
305
|
+
USER_PII_FIELDS, USER_PII_INDEX_MAP,
|
|
306
|
+
ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, BID_PII_FIELDS, PAYOUT_PII_FIELDS,
|
|
307
|
+
REVIEW_PII_FIELDS, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS,
|
|
308
|
+
// misc
|
|
309
|
+
generateFAQId,
|
|
310
|
+
/*
|
|
311
|
+
* Seed provenance. `stampSeedPurpose` is the ONE place a `seedPurpose` (and
|
|
312
|
+
* the `isTestData` / `testDataExpiresAt` derived from it) is produced, shared
|
|
313
|
+
* with `seed/runner.ts` so the two loaders cannot disagree about what a
|
|
314
|
+
* purpose means — Root Cause #75's shape on the seed axis.
|
|
315
|
+
*/
|
|
316
|
+
stampSeedPurpose,
|
|
317
|
+
resolveSeedPurpose,
|
|
318
|
+
SEED_COLLECTION_PURPOSE,
|
|
319
|
+
ALL_SEED_PURPOSES,
|
|
320
|
+
} = appkit;
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Collection registry
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
const COLLECTION_MAP = {
|
|
326
|
+
users: USER_COLLECTION,
|
|
327
|
+
addresses: "addresses",
|
|
328
|
+
storeAddresses: "storeAddresses",
|
|
329
|
+
couponUsage: "couponUsage",
|
|
330
|
+
categories: CATEGORIES_COLLECTION,
|
|
331
|
+
stores: STORE_COLLECTION,
|
|
332
|
+
products: PRODUCT_COLLECTION,
|
|
333
|
+
orders: ORDER_COLLECTION,
|
|
334
|
+
reviews: REVIEW_COLLECTION,
|
|
335
|
+
bids: BID_COLLECTION,
|
|
336
|
+
coupons: COUPONS_COLLECTION,
|
|
337
|
+
carousels: CAROUSELS_COLLECTION,
|
|
338
|
+
carouselSlides: CAROUSEL_SLIDES_COLLECTION,
|
|
339
|
+
homepageSections: HOMEPAGE_SECTIONS_COLLECTION,
|
|
340
|
+
siteSettings: SITE_SETTINGS_COLLECTION,
|
|
341
|
+
faqs: FAQS_COLLECTION,
|
|
342
|
+
notifications: NOTIFICATIONS_COLLECTION,
|
|
343
|
+
payouts: PAYOUT_COLLECTION,
|
|
344
|
+
offers: OFFER_COLLECTION,
|
|
345
|
+
/*
|
|
346
|
+
* String literals, not constants, because SUPPORT_TICKET_COLLECTION,
|
|
347
|
+
* CATALOGUE_COLLECTION and SHIPMENT_COLLECTION are NOT re-exported from
|
|
348
|
+
* appkit's barrel -- only their features' own schema files declare them.
|
|
349
|
+
* Adding three barrel exports to fix a seeding gap is a public-API change
|
|
350
|
+
* for a private need, so the names are duplicated here with the source
|
|
351
|
+
* named beside each. If one is ever renamed, this is the site that breaks.
|
|
352
|
+
*/
|
|
353
|
+
supportTickets: "supportTickets", // features/support/schemas/firestore.ts:279
|
|
354
|
+
catalogueItems: "catalogueItems", // features/catalogue/schemas/firestore.ts:18
|
|
355
|
+
procurementShipments: "procurementShipments", // features/shipments/schemas/firestore.ts:26
|
|
356
|
+
shipmentLots: "shipmentLots",
|
|
357
|
+
shipmentItems: "shipmentItems",
|
|
358
|
+
/*
|
|
359
|
+
* store-extensions. Ten collections with seed fixtures in manifest.ts that
|
|
360
|
+
* were NEVER in this map, so ALL_COLLECTIONS omitted them and they held ZERO
|
|
361
|
+
* documents in every run ever -- Root Cause #90, instances 7-16. Found via
|
|
362
|
+
* /store/analytics/cards rendering "No cards" under copy promising built-ins.
|
|
363
|
+
* String literals for the same reason as the block above: none of these is
|
|
364
|
+
* re-exported from appkit's barrel.
|
|
365
|
+
*/
|
|
366
|
+
payoutMethods: "payoutMethods", // features/store-extensions/schemas/firestore.ts:46
|
|
367
|
+
shippingConfigs: "shippingConfigs", // :84
|
|
368
|
+
analyticsCards: "analyticsCards", // :120
|
|
369
|
+
analyticsAlerts: "analyticsAlerts", // :152
|
|
370
|
+
storeCategories: "storeCategories", // :179
|
|
371
|
+
listingTemplates: "listingTemplates", // :240
|
|
372
|
+
moderationQueue: "moderationQueue", // :280
|
|
373
|
+
reports: "reports", // :372
|
|
374
|
+
itemRequests: "itemRequests",
|
|
375
|
+
storeGoogleConfig: "storeGoogleConfig",
|
|
376
|
+
blogPosts: BLOG_POSTS_COLLECTION,
|
|
377
|
+
events: EVENTS_COLLECTION,
|
|
378
|
+
eventEntries: EVENT_ENTRIES_COLLECTION,
|
|
379
|
+
sessions: SESSION_COLLECTION,
|
|
380
|
+
carts: CART_COLLECTION,
|
|
381
|
+
wishlists: WISHLIST_COLLECTION,
|
|
382
|
+
history: HISTORY_COLLECTION,
|
|
383
|
+
groupedListings: GROUPED_LISTINGS_COLLECTION,
|
|
384
|
+
/*
|
|
385
|
+
* `/media/<shortId>` resolves ONLY through a mediaAssets document, so without
|
|
386
|
+
* these rows every seeded cover image 404s while its object sits untouched in
|
|
387
|
+
* the bucket. Seeded rather than left as a side effect of the upload script
|
|
388
|
+
* precisely so a Firestore wipe cannot orphan Storage.
|
|
389
|
+
*/
|
|
390
|
+
mediaAssets: "mediaAssets",
|
|
391
|
+
chartOfAccounts: "chartOfAccounts",
|
|
392
|
+
scammerProfiles: SCAMMER_COLLECTION,
|
|
393
|
+
productFeatures: PRODUCT_FEATURES_COLLECTION,
|
|
394
|
+
authenticityKnowledge: "authenticityKnowledge",
|
|
395
|
+
taxCodes: TAX_CODES_COLLECTION,
|
|
396
|
+
/*
|
|
397
|
+
* Staff-creatable, but SEEDED — otherwise the first buyer to hit a problem
|
|
398
|
+
* gets a required dropdown with no options, which is a dead end they cannot
|
|
399
|
+
* work around and which reads as the form being broken.
|
|
400
|
+
*/
|
|
401
|
+
disputeReasons: DISPUTE_REASON_COLLECTION,
|
|
402
|
+
// E4/D12 — extracted from the local corpus, generated into the seed.
|
|
403
|
+
partGlossary: PART_GLOSSARY_COLLECTION,
|
|
404
|
+
/*
|
|
405
|
+
* The BEY reference database. Mirrors `partGlossary`: extracted from a
|
|
406
|
+
* corpus by committed scripts, shares its id with the paired category leaf,
|
|
407
|
+
* and keeps measured data OFF `categories` so a reseed cannot drop every
|
|
408
|
+
* bey page's ISR entry.
|
|
409
|
+
*/
|
|
410
|
+
beys: BEYS_COLLECTION,
|
|
411
|
+
beyProducts: BEY_PRODUCTS_COLLECTION,
|
|
412
|
+
modelComposition: MODEL_COMPOSITION_COLLECTION,
|
|
413
|
+
testerChecklistItems: TESTER_CHECKLIST_ITEM_COLLECTION,
|
|
414
|
+
/*
|
|
415
|
+
* Tester submissions. DERIVED tier in the tester's own map (wiped at setup so
|
|
416
|
+
* a prior run's verdicts never leak into the next), AND listed in
|
|
417
|
+
* SEED_TRANSACTIONAL so these two demo fixtures are restored afterwards —
|
|
418
|
+
* that list is a RESTORE list, orthogonal to the delete tiers.
|
|
419
|
+
*
|
|
420
|
+
* Without the fixtures, three admin/bug-hunter-rewards cases have nothing to
|
|
421
|
+
* confirm or reopen and can only report blocked. Measured 2026-09-11:
|
|
422
|
+
* 0 pass / 0 fail / 5 blocked, both controls correct.
|
|
423
|
+
*/
|
|
424
|
+
testerChecklistResponses: TESTER_CHECKLIST_RESPONSE_COLLECTION,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/*
|
|
428
|
+
* B6 — stamp `gstRate` + `hsnCode` onto every seeded product.
|
|
429
|
+
*
|
|
430
|
+
* 🛑 WITHOUT THIS, EVERY SEEDED PRODUCT IS TAXED AT ZERO, and the Tax row can
|
|
431
|
+
* never render no matter what the GSTIN says.
|
|
432
|
+
*
|
|
433
|
+
* The resolution order C0c settled lives in `ProductRepository.deriveTaxonomy`
|
|
434
|
+
* — product rate, else the CATEGORY's via `taxCodeId`, else a warned floor —
|
|
435
|
+
* and it runs on a repository write. **The seed writes through
|
|
436
|
+
* `set(..., { merge: true })` and never touches the repository**, so no seeded
|
|
437
|
+
* row has ever carried a rate: measured 2026-10-12, `gstRate` was absent on
|
|
438
|
+
* all 70 live products. At checkout `sumGroupGst` reads `product.gstRate`
|
|
439
|
+
* directly with a `?? 0`, so the whole catalogue would have computed GST = 0
|
|
440
|
+
* with a valid GSTIN and `gstSettings.enabled: true`.
|
|
441
|
+
*
|
|
442
|
+
* That is the same class as Root Cause #101 (a schema that strips a field plus
|
|
443
|
+
* a read-side backfill equals a field no query can see) — here it is a
|
|
444
|
+
* write-side derivation the seed path does not run.
|
|
445
|
+
*
|
|
446
|
+
* 🛑 This is NOT a second implementation of the resolution. The product seed
|
|
447
|
+
* already hand-writes the FULL `categorySlugs` chain, so there is no walk to
|
|
448
|
+
* re-derive: take the chain, find the first element whose category declares a
|
|
449
|
+
* `taxCodeId`, and read the rate off the `taxCodes` seed. Nearest-first, which
|
|
450
|
+
* is the order `resolveCategoryDefaults` uses, so a more specific category
|
|
451
|
+
* still wins.
|
|
452
|
+
*
|
|
453
|
+
* 🛑 An explicit rate on a product WINS and is never overwritten — including
|
|
454
|
+
* `gstRate: 0`, which is a deliberate exemption and not an absence
|
|
455
|
+
* (`ProductDocument.gstRate`'s own JSDoc: "undefined and 0 MEAN DIFFERENT
|
|
456
|
+
* THINGS"). The test is `== null`, never falsy.
|
|
457
|
+
*
|
|
458
|
+
* 🛑 And it REFUSES rather than defaulting. A product whose chain resolves no
|
|
459
|
+
* tax code throws at load time: C0c defines reaching the floor as a triage
|
|
460
|
+
* signal, so a silent 5% here would hide exactly what it is meant to surface —
|
|
461
|
+
* and a wrong rate on a real invoice is worse than a failed load.
|
|
462
|
+
*/
|
|
463
|
+
/**
|
|
464
|
+
* Stamp `franchise` / `franchiseSlug` from the product's category ROOT.
|
|
465
|
+
*
|
|
466
|
+
* 🛑 Its OWN pass, deliberately NOT folded into `withDerivedCategoryFields`.
|
|
467
|
+
* That function early-returns on `gstRate && hsnCode && authenticity`, which
|
|
468
|
+
* every one of the 1,237 generated catalogue rows already satisfies — so a
|
|
469
|
+
* franchise derived in there would be silently skipped for 95% of the
|
|
470
|
+
* catalogue. That is the exact bug its own comment records happening when
|
|
471
|
+
* `authenticity` was added beside `gstRate`; adding a third field to the same
|
|
472
|
+
* condition is how it happens a third time.
|
|
473
|
+
*
|
|
474
|
+
* A root with no mapping yields NO franchise (living collectibles are not an
|
|
475
|
+
* IP) — see `FRANCHISE_BY_CATEGORY_ROOT`.
|
|
476
|
+
*/
|
|
477
|
+
/**
|
|
478
|
+
* The real, sellable catalogue — everything else in the product seed is demo
|
|
479
|
+
* furniture and is DROPPED HERE, before anything is written.
|
|
480
|
+
*
|
|
481
|
+
* ── 🛑 WHY A FILTER AND NOT A DELETION ────────────────────────────────────
|
|
482
|
+
*
|
|
483
|
+
* The fixtures stay in their files on purpose: they are the only coverage
|
|
484
|
+
* several features have (every `preOrderProductionStatus` value, a sold-out
|
|
485
|
+
* reservation, a depleted digital-code pool, an ended-unsold auction), and
|
|
486
|
+
* the tester checklist deep-links to them by id. Deleting them would silently
|
|
487
|
+
* remove that coverage. Not LOADING them is the part that was wanted.
|
|
488
|
+
*
|
|
489
|
+
* ── 🛑 WHY THIS HAD TO EXIST ──────────────────────────────────────────────
|
|
490
|
+
*
|
|
491
|
+
* `appkit-seed load` writes every registered row and `purge` deletes
|
|
492
|
+
* `seedPurpose: "test"` afterwards — so the demo listings came BACK on every
|
|
493
|
+
* reseed, and anyone who ran `load` to fix imagery silently republished 76
|
|
494
|
+
* fake products to the live storefront. That happened twice in one session.
|
|
495
|
+
* The owner's instruction was "delete them from the seed itself and make sure
|
|
496
|
+
* they do not return"; this is the point where that can be guaranteed, since
|
|
497
|
+
* every write path funnels through here.
|
|
498
|
+
*
|
|
499
|
+
* ── The discriminator is the STORE, not the listing type ──────────────────
|
|
500
|
+
*
|
|
501
|
+
* `store-beyblade-arena` is the fictional demo seller; `store-letitrip-
|
|
502
|
+
* official` is the real one. Keying on the store rather than on a per-file
|
|
503
|
+
* allow-list means a fixture added to ANY product seed file later is excluded
|
|
504
|
+
* automatically, which is the failure mode this is replacing — a list that
|
|
505
|
+
* has to be remembered is a list that drifts.
|
|
506
|
+
*
|
|
507
|
+
* Set `SEED_INCLUDE_DEMO=1` to load them anyway, for local demo work.
|
|
508
|
+
*/
|
|
509
|
+
const REAL_STORE_IDS = new Set(["store-letitrip-official"]);
|
|
510
|
+
|
|
511
|
+
function realListingsOnly(products) {
|
|
512
|
+
if (process.env.SEED_INCLUDE_DEMO === "1") {
|
|
513
|
+
console.log("▌ SEED_INCLUDE_DEMO=1 — demo listings INCLUDED");
|
|
514
|
+
return products;
|
|
515
|
+
}
|
|
516
|
+
/*
|
|
517
|
+
* 🛑 TWO rules, because the store alone is not enough. Six prize-draw
|
|
518
|
+
* fixtures live under the REAL store and are still demo furniture — their
|
|
519
|
+
* imagery is a 460-byte generated colour tile from /images/seed-tiles/,
|
|
520
|
+
* which is the giveaway: a listing nobody photographed is a listing nobody
|
|
521
|
+
* intends to sell. Measured before writing this: every one of the six, and
|
|
522
|
+
* nothing real, matches.
|
|
523
|
+
*/
|
|
524
|
+
const isPlaceholder = (p) => {
|
|
525
|
+
const urls = [p?.mainImage, ...(Array.isArray(p?.images) ? p.images : [])]
|
|
526
|
+
.map((v) => (typeof v === "string" ? v : v?.url))
|
|
527
|
+
.filter(Boolean);
|
|
528
|
+
return urls.length > 0 && urls.every((u) => String(u).includes("/images/seed-tiles/"));
|
|
529
|
+
};
|
|
530
|
+
const kept = products.filter((p) => REAL_STORE_IDS.has(p?.storeId) && !isPlaceholder(p));
|
|
531
|
+
const dropped = products.length - kept.length;
|
|
532
|
+
if (dropped > 0) {
|
|
533
|
+
console.log(` • ${dropped} demo listing(s) not loaded (set SEED_INCLUDE_DEMO=1 to include)`);
|
|
534
|
+
}
|
|
535
|
+
return kept;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Drop bundle categories whose members are not in the loaded catalogue.
|
|
540
|
+
*
|
|
541
|
+
* 🛑 A bundle is ALL-OR-NOTHING — `canAddToCart` buys every member together —
|
|
542
|
+
* so a bundle missing even one member is unsellable, and one missing ALL of
|
|
543
|
+
* them is a card that opens an empty page. Measured right after the demo
|
|
544
|
+
* listings stopped loading: of the five seeded bundles, three had zero
|
|
545
|
+
* surviving members and the other two had 1-of-4 and 2-of-3. All five were
|
|
546
|
+
* still `isActive: true`, and all five rendered on the HOMEPAGE as collage
|
|
547
|
+
* cards built from `display.mosaicImages` — which are baked at seed time from
|
|
548
|
+
* the members' own imagery, so they kept showing pictures of products that no
|
|
549
|
+
* longer exist. That is 92 placeholder-tile references on the homepage.
|
|
550
|
+
*
|
|
551
|
+
* Derived rather than hand-maintained for the same reason as the product
|
|
552
|
+
* filter: a bundle that becomes valid later starts loading again on its own,
|
|
553
|
+
* and one that breaks stops, with nobody having to remember either.
|
|
554
|
+
*
|
|
555
|
+
* Only `categoryType: "bundle"` rows are considered. Every other category is
|
|
556
|
+
* taxonomy and is never touched — rebuilding the tree is expensive and is
|
|
557
|
+
* explicitly not what this is for.
|
|
558
|
+
*/
|
|
559
|
+
function loadableBundlesOnly(categories, loadedProducts) {
|
|
560
|
+
const live = new Set(loadedProducts.map((p) => p?.id).filter(Boolean));
|
|
561
|
+
let dropped = 0;
|
|
562
|
+
const kept = categories.filter((c) => {
|
|
563
|
+
if (c?.categoryType !== "bundle") return true;
|
|
564
|
+
const ids = Array.isArray(c.bundleProductIds) ? c.bundleProductIds : [];
|
|
565
|
+
const ok = ids.length > 0 && ids.every((id) => live.has(id));
|
|
566
|
+
if (!ok) dropped++;
|
|
567
|
+
return ok;
|
|
568
|
+
});
|
|
569
|
+
if (dropped > 0) {
|
|
570
|
+
console.log(` • ${dropped} bundle(s) not loaded — members missing from the catalogue`);
|
|
571
|
+
}
|
|
572
|
+
return kept;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function withFranchise(products) {
|
|
576
|
+
let stamped = 0;
|
|
577
|
+
const out = products.map((p) => {
|
|
578
|
+
if (p.franchiseSlug) return p;
|
|
579
|
+
const chain = Array.isArray(p.categorySlugs) ? p.categorySlugs : [];
|
|
580
|
+
const root = chain.length > 0 ? String(chain[chain.length - 1]) : null;
|
|
581
|
+
const f = root ? FRANCHISE_BY_CATEGORY_ROOT[root] : null;
|
|
582
|
+
if (!f) return p;
|
|
583
|
+
stamped++;
|
|
584
|
+
return { ...p, franchise: f.name, franchiseSlug: f.slug };
|
|
585
|
+
});
|
|
586
|
+
console.log(` • franchise stamped on ${stamped}/${products.length} products`);
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function withDerivedCategoryFields(products) {
|
|
591
|
+
const catById = new Map((categoriesSeedData || []).map((c) => [String(c.id), c]));
|
|
592
|
+
const taxById = new Map((taxCodesSeedData || []).map((t) => [String(t.id), t]));
|
|
593
|
+
const unresolved = [];
|
|
594
|
+
const out = products.map((p) => {
|
|
595
|
+
// 🛑 Must NOT early-exit on tax alone now that authenticity rides along —
|
|
596
|
+
// a product with a hand-written rate would silently keep no authenticity.
|
|
597
|
+
if (p.gstRate != null && p.hsnCode && p.authenticity) return p;
|
|
598
|
+
const chain = Array.isArray(p.categorySlugs) && p.categorySlugs.length
|
|
599
|
+
? p.categorySlugs
|
|
600
|
+
: (p.category ? [p.category] : []);
|
|
601
|
+
let code = null;
|
|
602
|
+
for (const id of chain) {
|
|
603
|
+
const c = catById.get(String(id));
|
|
604
|
+
const t = c?.productDefaults?.taxCodeId;
|
|
605
|
+
if (t) { code = t; break; }
|
|
606
|
+
}
|
|
607
|
+
const tax = code ? taxById.get(String(code)) : null;
|
|
608
|
+
if (!tax) { unresolved.push(`${p.id} (leaf ${chain[0] ?? "none"})`); return p; }
|
|
609
|
+
|
|
610
|
+
/*
|
|
611
|
+
* 🛑 AUTHENTICITY, the same gap one field over — and it was making a HERO
|
|
612
|
+
* CLAIM false.
|
|
613
|
+
*
|
|
614
|
+
* `productDefaults.defaultAuthenticity` is declared on 122 categories and
|
|
615
|
+
* `authenticity` was absent on **all 71 live products**, because it derives
|
|
616
|
+
* on a repository write and the seed does not make one. The homepage
|
|
617
|
+
* authenticity slide says "the seller has to pick one before it can go
|
|
618
|
+
* live" — which was untrue in two ways at once: the write schema has it
|
|
619
|
+
* `.optional()`, and not one product carried a value.
|
|
620
|
+
*
|
|
621
|
+
* `unverified` is the floor, never `original`: `ProductAuthenticity`'s own
|
|
622
|
+
* docstring says that without it "an honest seller who does not know would
|
|
623
|
+
* have to pick 'original', and silence would be indistinguishable from a
|
|
624
|
+
* genuine claim." The platform must not make an authenticity claim nobody
|
|
625
|
+
* checked.
|
|
626
|
+
*
|
|
627
|
+
* An explicit product value always wins, so the 6 categories that declare
|
|
628
|
+
* `original` still reach their products.
|
|
629
|
+
*/
|
|
630
|
+
let authenticity = null;
|
|
631
|
+
for (const id of chain) {
|
|
632
|
+
const c = catById.get(String(id));
|
|
633
|
+
const a = c?.productDefaults?.defaultAuthenticity;
|
|
634
|
+
if (a) { authenticity = a; break; }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
...p,
|
|
639
|
+
gstRate: p.gstRate != null ? p.gstRate : tax.gstRate,
|
|
640
|
+
// An exempt code has no HSN, and `hsnCode: ""` would read as a declared
|
|
641
|
+
// empty code rather than as an inapplicable one.
|
|
642
|
+
...(p.hsnCode || !tax.hsnCode ? {} : { hsnCode: tax.hsnCode }),
|
|
643
|
+
...(p.authenticity || !authenticity ? {} : { authenticity }),
|
|
644
|
+
};
|
|
645
|
+
});
|
|
646
|
+
if (unresolved.length) {
|
|
647
|
+
const detail = unresolved.slice(0, 10).join(", ");
|
|
648
|
+
throw new Error(
|
|
649
|
+
`seed: ${unresolved.length} product(s) resolve NO taxCodeId from their category chain, ` +
|
|
650
|
+
"so they would be taxed at zero. Give the category a taxCodeId " +
|
|
651
|
+
`(the roots carry a floor - see ROOT_TAX_DEFAULTS): ${detail}`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
return out;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The real catalogue, computed ONCE so the bundle filter and the product
|
|
659
|
+
* loader cannot disagree about what "loaded" means. Deriving it twice is
|
|
660
|
+
* Root Cause #75's shape — two copies of one rule, one of which learns about
|
|
661
|
+
* a change and the other does not.
|
|
662
|
+
*/
|
|
663
|
+
const REAL_PRODUCTS = realListingsOnly([
|
|
664
|
+
...(productsStandardSeedData || []),
|
|
665
|
+
// Real, wiki-verified Beyblade X listings under store-letitrip-official —
|
|
666
|
+
// added 2026-10-19 for PhonePe merchant registration, which requires at
|
|
667
|
+
// least one genuinely purchasable product. See the file's own header.
|
|
668
|
+
...(productsBeybladeXSeedData || []),
|
|
669
|
+
...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
|
|
670
|
+
...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
|
|
671
|
+
// art + stickers were written, exported from the seed barrel, and then never
|
|
672
|
+
// spread here — so 13 fixtures had NEVER reached the database, /art and
|
|
673
|
+
// /stickers were permanently empty, and listingType=art|stickers returned 0.
|
|
674
|
+
// Root Cause #58's family one layer down: added everywhere except the runner.
|
|
675
|
+
...(productsArtSeedData || []), ...(productsStickersSeedData || []),
|
|
676
|
+
// 🛑 BOTH sites, every time. This file's own header records 13 art/sticker
|
|
677
|
+
// fixtures that never reached the database because only the import was
|
|
678
|
+
// edited and this spread was not — the loader then wrote every OTHER
|
|
679
|
+
// product and reported success.
|
|
680
|
+
...(productsSparesSeedData || []),
|
|
681
|
+
/*
|
|
682
|
+
* 🛑 DEREGISTERED 2026-10-19 — productsCatalogueSeedData (1,237 rows, one
|
|
683
|
+
* per taxonomy leaf) is NOT spread here, on purpose.
|
|
684
|
+
*
|
|
685
|
+
* This is a reversal of the comment that used to be here, which said the
|
|
686
|
+
* opposite: that this spread was "what makes `products` reproducible
|
|
687
|
+
* after a full wipe." That was true when the catalogue was the only
|
|
688
|
+
* content under its leaf categories. It stopped being true the moment
|
|
689
|
+
* real curated listings exist for the SAME leaves — four of them,
|
|
690
|
+
* confirmed by grep: `category-aeropegasus-3-70a`, `category-
|
|
691
|
+
* valkyrievolt-s4-70v`, `category-wizardarrow-4-80b`, `category-
|
|
692
|
+
* knightshield-3-80n` each already carry one generated row. Re-enabling
|
|
693
|
+
* this spread would not just add filler back — it would put a
|
|
694
|
+
* hash-priced synthetic duplicate (one measured as a NEGATIVE price,
|
|
695
|
+
* `-61`) into the exact categories the real BX-53 fixture now occupies,
|
|
696
|
+
* silently resurrecting the thing a full `appkit-seed purge` was just run
|
|
697
|
+
* to remove.
|
|
698
|
+
*
|
|
699
|
+
* The generator (`scripts/gen-catalogue-listings.mjs`) and the committed
|
|
700
|
+
* file itself are both left untouched — this is deliberately a one-line
|
|
701
|
+
* "stop loading it" change, not a deletion. Re-enable only after deciding
|
|
702
|
+
* what should happen to a leaf that now has BOTH a real listing and a
|
|
703
|
+
* generated one.
|
|
704
|
+
*/
|
|
705
|
+
]);
|
|
706
|
+
|
|
707
|
+
const SEED_DATA_MAP = {
|
|
708
|
+
users: usersSeedData,
|
|
709
|
+
/*
|
|
710
|
+
* store-extensions. COLLECTION_MAP was only HALF the wiring: load() reads
|
|
711
|
+
* SEED_DATA_MAP and short-circuits on an empty array, so adding the ten
|
|
712
|
+
* collection names alone produced "no seed data, skipping" for all ten.
|
|
713
|
+
* Same shape as the art/stickers note further down -- added everywhere
|
|
714
|
+
* except the map that actually runs.
|
|
715
|
+
*/
|
|
716
|
+
payoutMethods: payoutMethodsSeedData,
|
|
717
|
+
shippingConfigs: shippingConfigsSeedData,
|
|
718
|
+
analyticsCards: analyticsCardsSeedData,
|
|
719
|
+
analyticsAlerts: analyticsAlertsSeedData,
|
|
720
|
+
storeCategories: storeCategoriesSeedData,
|
|
721
|
+
listingTemplates: listingTemplatesSeedData,
|
|
722
|
+
moderationQueue: moderationQueueSeedData,
|
|
723
|
+
reports: reportsSeedData,
|
|
724
|
+
itemRequests: itemRequestsSeedData,
|
|
725
|
+
storeGoogleConfig: storeGoogleConfigSeedData,
|
|
726
|
+
addresses: addressesSeedData,
|
|
727
|
+
storeAddresses: storeAddressesSeedData,
|
|
728
|
+
// SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
|
|
729
|
+
// standalone brands collection/seed exists anymore.
|
|
730
|
+
categories: loadableBundlesOnly(categoriesSeedData, REAL_PRODUCTS),
|
|
731
|
+
stores: storesSeedData,
|
|
732
|
+
products: withFranchise(withDerivedCategoryFields(REAL_PRODUCTS)),
|
|
733
|
+
orders: ordersSeedData,
|
|
734
|
+
reviews: reviewsSeedData,
|
|
735
|
+
bids: bidsSeedData,
|
|
736
|
+
coupons: couponsSeedData,
|
|
737
|
+
couponUsage: couponUsageSeedData,
|
|
738
|
+
carousels: carouselsSeedData,
|
|
739
|
+
carouselSlides: carouselSlidesSeedData,
|
|
740
|
+
homepageSections: homepageSectionsSeedData,
|
|
741
|
+
siteSettings: [siteSettingsSeedData],
|
|
742
|
+
faqs: faqSeedData,
|
|
743
|
+
notifications: notificationsSeedData,
|
|
744
|
+
payouts: payoutsSeedData,
|
|
745
|
+
// The LOADER was missing for a long time, so `offers` never appeared in
|
|
746
|
+
// `appkit-seed status` and no run had an offer in Firestore. OFFER_PII_FIELDS
|
|
747
|
+
// was already imported and mapped below -- the PII half was wired and the
|
|
748
|
+
// loading half never was, which is why the gap looked like support rather
|
|
749
|
+
// than an omission.
|
|
750
|
+
offers: offersSeedData,
|
|
751
|
+
// All four were unregistered like offers, all measured at 0 documents live.
|
|
752
|
+
// They are UNLISTED in the tester's tier map, which means PRESERVE -- so
|
|
753
|
+
// seeding them once persists across every future run rather than being
|
|
754
|
+
// wiped by the next tester:setup.
|
|
755
|
+
supportTickets: supportTicketsSeedData,
|
|
756
|
+
catalogueItems: catalogueSeedData,
|
|
757
|
+
procurementShipments: shipmentsSeedData,
|
|
758
|
+
shipmentLots: shipmentLotsSeedData,
|
|
759
|
+
shipmentItems: shipmentItemsSeedData,
|
|
760
|
+
blogPosts: blogPostsSeedData,
|
|
761
|
+
events: eventsSeedData,
|
|
762
|
+
eventEntries: eventEntriesSeedData,
|
|
763
|
+
sessions: sessionsSeedData,
|
|
764
|
+
carts: cartsSeedData,
|
|
765
|
+
wishlists: wishlistsSeedData,
|
|
766
|
+
history: historySeedData,
|
|
767
|
+
groupedListings: groupedListingsSeedData,
|
|
768
|
+
// The other half of the wiring — load() short-circuits on an empty array, so
|
|
769
|
+
// the COLLECTION_MAP entry above does nothing on its own (Root Cause #90).
|
|
770
|
+
// Two source files, one collection: the taxonomy rows are GENERATED and
|
|
771
|
+
// rewritten wholesale by upload-taxonomy-images.mjs, so the editorial rows
|
|
772
|
+
// are kept beside them rather than inside that file.
|
|
773
|
+
mediaAssets: [...mediaAssetsSeedData, ...mediaAssetsEditorialSeedData],
|
|
774
|
+
chartOfAccounts: chartOfAccountsSeedData,
|
|
775
|
+
scammerProfiles: scammersSeedData,
|
|
776
|
+
productFeatures: productFeaturesSeedData,
|
|
777
|
+
authenticityKnowledge: authenticityKnowledgeSeedData,
|
|
778
|
+
taxCodes: taxCodesSeedData,
|
|
779
|
+
disputeReasons: disputeReasonsSeedData,
|
|
780
|
+
partGlossary: partGlossarySeedData,
|
|
781
|
+
// 🛑 Both halves are mandatory: load() short-circuits on an empty array, so a
|
|
782
|
+
// COLLECTION_MAP entry alone logs "no seed data, skipping" and exits 0.
|
|
783
|
+
beys: beysSeedData,
|
|
784
|
+
beyProducts: beyProductsSeedData,
|
|
785
|
+
modelComposition: modelCompositionSeedData,
|
|
786
|
+
/*
|
|
787
|
+
* 🛑 ABSENT ON PURPOSE — the checklist corpus moved to `tester/cases/`.
|
|
788
|
+
*
|
|
789
|
+
* `node tester/scripts/seed-cases.mjs` writes `testerChecklistItems` now.
|
|
790
|
+
* The cases are authoring data for that harness, and nothing in the app read
|
|
791
|
+
* them: the admin surfaces and the API route all read the COLLECTION through
|
|
792
|
+
* repositories that stay in appkit.
|
|
793
|
+
*
|
|
794
|
+
* Leaving the keys out of SEED_DATA_MAP is what makes `load` skip them —
|
|
795
|
+
* `status` will report them as `0 seed`, which is correct and not the
|
|
796
|
+
* "no seed data, skipping" trap of Root Cause #90, because the data is not
|
|
797
|
+
* missing, it is owned elsewhere.
|
|
798
|
+
*/
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const ALL_COLLECTIONS = Object.keys(COLLECTION_MAP);
|
|
802
|
+
const REPLACE_ON_LOAD = new Set(["homepageSections", "carouselSlides"]);
|
|
803
|
+
|
|
804
|
+
const collectionsToProcess = ONLY_SET
|
|
805
|
+
? ALL_COLLECTIONS.filter((c) => ONLY_SET.has(c))
|
|
806
|
+
: ALL_COLLECTIONS;
|
|
807
|
+
|
|
808
|
+
if (ONLY_SET && collectionsToProcess.length === 0) {
|
|
809
|
+
console.error(`✗ No matching collections. Valid: ${ALL_COLLECTIONS.join(", ")}`);
|
|
810
|
+
process.exit(2);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// Generic helpers
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
function stripUndefined(obj) {
|
|
817
|
+
if (Array.isArray(obj)) return obj.map(stripUndefined);
|
|
818
|
+
if (obj instanceof Date) return obj;
|
|
819
|
+
if (obj !== null && typeof obj === "object") {
|
|
820
|
+
return Object.fromEntries(
|
|
821
|
+
Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => [k, stripUndefined(v)]),
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
return obj;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function purgeCollection(path) {
|
|
828
|
+
const BATCH = 500;
|
|
829
|
+
let snap = await db.collection(path).limit(BATCH).get();
|
|
830
|
+
let removed = 0;
|
|
831
|
+
while (!snap.empty) {
|
|
832
|
+
const batch = db.batch();
|
|
833
|
+
snap.docs.forEach((d) => batch.delete(d.ref));
|
|
834
|
+
await batch.commit();
|
|
835
|
+
removed += snap.docs.length;
|
|
836
|
+
if (snap.docs.length < BATCH) break;
|
|
837
|
+
snap = await db.collection(path).limit(BATCH).get();
|
|
838
|
+
}
|
|
839
|
+
return removed;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/*
|
|
843
|
+
* 🛑 `TOKENS_INDEX_MAP` holds ONLY `payouts`, and that is a measured fact
|
|
844
|
+
* about this file, not an oversight.
|
|
845
|
+
*
|
|
846
|
+
* `encryptSeedPii` is called from exactly one place — `loadGeneric`'s
|
|
847
|
+
* per-doc loop. `users` and `addresses` are seeded by their OWN dedicated
|
|
848
|
+
* loaders (`loadUsers`, the addresses block near line 1029) that write
|
|
849
|
+
* straight to Firestore and never call this function — so a `users`/
|
|
850
|
+
* `addresses` entry here would be dead code, silently unreachable, which is
|
|
851
|
+
* worse than no entry: it would read as "handled" to the next person who
|
|
852
|
+
* greps for it.
|
|
853
|
+
*
|
|
854
|
+
* It is also the right scope rather than a gap, because `users` and
|
|
855
|
+
* `addresses` are PRESERVE-tier in the tester harness — never wiped, never
|
|
856
|
+
* reseeded — so the only two things that can write their `*TokensIndex`
|
|
857
|
+
* are a REAL write (covered by `UserRepository`/`AddressesRepository`'s
|
|
858
|
+
* `buildTokensIndexFor`, wired separately) and the one-time bootstrap seed
|
|
859
|
+
* load `appkit-seed load --collections users` documented in CLAUDE.md's
|
|
860
|
+
* § "Claude Tester" — which `backfill-blind-index.mjs` exists to catch up
|
|
861
|
+
* after. `payouts` has no such safety net: it is SEED_TRANSACTIONAL, so it
|
|
862
|
+
* cascades and RESEEDS on every tester run that touches its store, through
|
|
863
|
+
* exactly this function — which is why it is the one entry that matters.
|
|
864
|
+
*/
|
|
865
|
+
const TOKENS_INDEX_MAP = {
|
|
866
|
+
payouts: { field: "sellerTokensIndex", from: ["sellerName", "sellerEmail"] },
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
function encryptSeedPii(collection, data) {
|
|
870
|
+
const PII_MAP = {
|
|
871
|
+
orders: ORDER_PII_FIELDS,
|
|
872
|
+
bids: BID_PII_FIELDS,
|
|
873
|
+
payouts: PAYOUT_PII_FIELDS,
|
|
874
|
+
reviews: REVIEW_PII_FIELDS,
|
|
875
|
+
products: ["sellerName", "sellerEmail"],
|
|
876
|
+
offers: OFFER_PII_FIELDS,
|
|
877
|
+
eventEntries: EVENT_ENTRY_PII_FIELDS,
|
|
878
|
+
};
|
|
879
|
+
const fields = PII_MAP[collection];
|
|
880
|
+
let result = fields ? encryptPiiFields(data, [...fields]) : { ...data };
|
|
881
|
+
if (collection === "payouts" && result.bankAccount) {
|
|
882
|
+
result.bankAccount = encryptPayoutBankAccount(result.bankAccount);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/*
|
|
886
|
+
* 🛑 THIS IS WHAT MAKES `payouts` SURVIVE A TESTER WIPE WITH A WORKING
|
|
887
|
+
* SEARCH INDEX. `payouts` is SEED_TRANSACTIONAL in the tester harness —
|
|
888
|
+
* it cascades on `storeId` whenever its store is wiped, then gets
|
|
889
|
+
* RE-SEEDED by exactly this function. Without this block, the reseeded
|
|
890
|
+
* documents would carry ciphertext and no `sellerTokensIndex` at all,
|
|
891
|
+
* silently undoing a one-shot backfill on the very next tester run.
|
|
892
|
+
*/
|
|
893
|
+
const tokensIndex = TOKENS_INDEX_MAP[collection];
|
|
894
|
+
if (tokensIndex) {
|
|
895
|
+
const values = tokensIndex.from.map((f) => data[f]).filter((v) => typeof v === "string");
|
|
896
|
+
if (values.length > 0) {
|
|
897
|
+
result[tokensIndex.field] = buildTokensIndex(...values);
|
|
898
|
+
result[BLIND_INDEX_KEY_VERSION_FIELD] = CURRENT_BLIND_INDEX_KEY_VERSION;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return result;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function countExisting(colName) {
|
|
906
|
+
const seed = SEED_DATA_MAP[colName] || [];
|
|
907
|
+
if (seed.length === 0) return 0;
|
|
908
|
+
|
|
909
|
+
if (colName === "addresses") {
|
|
910
|
+
// SB-UNI-A: top-level `addresses` collection, not users/{uid}/addresses.
|
|
911
|
+
const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
|
|
912
|
+
if (refs.length === 0) return 0;
|
|
913
|
+
const snaps = await db.getAll(...refs);
|
|
914
|
+
return snaps.filter((s) => s.exists).length;
|
|
915
|
+
}
|
|
916
|
+
if (colName === "storeAddresses") {
|
|
917
|
+
// Also lands in the top-level `addresses` collection (see loadStoreAddresses).
|
|
918
|
+
const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
|
|
919
|
+
if (refs.length === 0) return 0;
|
|
920
|
+
const snaps = await db.getAll(...refs);
|
|
921
|
+
return snaps.filter((s) => s.exists).length;
|
|
922
|
+
}
|
|
923
|
+
if (colName === "couponUsage") {
|
|
924
|
+
const refs = seed.filter((d) => d.userId && d.couponId).map((d) =>
|
|
925
|
+
db.collection(USER_COLLECTION).doc(d.userId).collection("couponUsage").doc(d.couponId));
|
|
926
|
+
if (refs.length === 0) return 0;
|
|
927
|
+
const snaps = await db.getAll(...refs);
|
|
928
|
+
return snaps.filter((s) => s.exists).length;
|
|
929
|
+
}
|
|
930
|
+
if (colName === "wishlists" || colName === "history") {
|
|
931
|
+
const refs = seed.filter((d) => d.id).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id));
|
|
932
|
+
if (refs.length === 0) return 0;
|
|
933
|
+
const snaps = await db.getAll(...refs);
|
|
934
|
+
return snaps.filter((s) => s.exists).length;
|
|
935
|
+
}
|
|
936
|
+
if (colName === "siteSettings") {
|
|
937
|
+
const snap = await db.collection(COLLECTION_MAP[colName]).doc("global").get();
|
|
938
|
+
return snap.exists ? 1 : 0;
|
|
939
|
+
}
|
|
940
|
+
if (colName === "users") {
|
|
941
|
+
const refs = seed.filter((d) => d.uid).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.uid));
|
|
942
|
+
if (refs.length === 0) return 0;
|
|
943
|
+
const snaps = await db.getAll(...refs);
|
|
944
|
+
return snaps.filter((s) => s.exists).length;
|
|
945
|
+
}
|
|
946
|
+
if (colName === "faqs") {
|
|
947
|
+
// Mirrors loadGeneric: prefer the explicit id (every current faq record has
|
|
948
|
+
// one), only derive via generateFAQId for a record that genuinely lacks one.
|
|
949
|
+
const refs = seed.map((faq) => {
|
|
950
|
+
const id = faq.id ?? generateFAQId({ category: faq.category, question: faq.question });
|
|
951
|
+
return db.collection(COLLECTION_MAP[colName]).doc(id);
|
|
952
|
+
});
|
|
953
|
+
if (refs.length === 0) return 0;
|
|
954
|
+
const snaps = await db.getAll(...refs);
|
|
955
|
+
return snaps.filter((s) => s.exists).length;
|
|
956
|
+
}
|
|
957
|
+
// Mirrors loadGeneric's docId = id ?? slug resolution.
|
|
958
|
+
const refs = seed.filter((d) => d.id ?? d.slug).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id ?? d.slug));
|
|
959
|
+
if (refs.length === 0) return 0;
|
|
960
|
+
const snaps = await db.getAll(...refs);
|
|
961
|
+
return snaps.filter((s) => s.exists).length;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
// Status
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
async function cmdStatus() {
|
|
968
|
+
console.log(`\n${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)}`);
|
|
969
|
+
console.log("─".repeat(40));
|
|
970
|
+
for (const c of collectionsToProcess) {
|
|
971
|
+
const seedCount = (SEED_DATA_MAP[c] || []).length;
|
|
972
|
+
const existing = await countExisting(c).catch((e) => { console.error(` err: ${c}: ${e.message}`); return 0; });
|
|
973
|
+
const marker = existing === seedCount ? "✓" : existing === 0 ? " " : "~";
|
|
974
|
+
console.log(`${marker} ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)}`);
|
|
975
|
+
}
|
|
976
|
+
console.log();
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ---------------------------------------------------------------------------
|
|
980
|
+
// Confirm prompt
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
async function confirm(msg) {
|
|
983
|
+
if (SKIP_CONFIRM) return true;
|
|
984
|
+
return new Promise((resolveP) => {
|
|
985
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
986
|
+
rl.question(`${msg} [y/N] `, (a) => { rl.close(); resolveP(/^y(es)?$/i.test(a.trim())); });
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
// Per-collection writers (mirror of /api/demo/seed POST)
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
async function resolveAuthConflicts(uid, authUserData) {
|
|
994
|
+
if (authUserData.email) {
|
|
995
|
+
try {
|
|
996
|
+
const conflict = await auth.getUserByEmail(authUserData.email);
|
|
997
|
+
if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
|
|
998
|
+
} catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
|
|
999
|
+
}
|
|
1000
|
+
if (authUserData.phoneNumber) {
|
|
1001
|
+
try {
|
|
1002
|
+
const conflict = await auth.getUserByPhoneNumber(authUserData.phoneNumber);
|
|
1003
|
+
if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
|
|
1004
|
+
} catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function loadUsers(seed, stats) {
|
|
1009
|
+
for (const u of seed) {
|
|
1010
|
+
try {
|
|
1011
|
+
const { uid, email, phoneNumber, displayName, photoURL, emailVerified, disabled } = u;
|
|
1012
|
+
const docRef = db.collection(USER_COLLECTION).doc(uid);
|
|
1013
|
+
let userExists = false;
|
|
1014
|
+
try { await auth.getUser(uid); userExists = true; }
|
|
1015
|
+
catch (e) { if (e.code !== "auth/user-not-found") throw e; }
|
|
1016
|
+
|
|
1017
|
+
const authUserData = { displayName, emailVerified, disabled };
|
|
1018
|
+
if (email && typeof email === "string") authUserData.email = email;
|
|
1019
|
+
if (phoneNumber && typeof phoneNumber === "string") authUserData.phoneNumber = phoneNumber;
|
|
1020
|
+
// Firebase Auth's photoURL field requires an absolute URL. Seed data's
|
|
1021
|
+
// photoURL is wrapped by seedExtMedia() into a relative /media/ext?url=...
|
|
1022
|
+
// app-proxy path — valid for Firestore + the app's own avatar rendering,
|
|
1023
|
+
// but auth.createUser()/updateUser() reject it with "must be a valid URL"
|
|
1024
|
+
// and abort the WHOLE user record (Auth + Firestore both skipped) before
|
|
1025
|
+
// ever reaching the Firestore write below. The app never reads Auth's own
|
|
1026
|
+
// photoURL copy anyway — it always resolves avatars via the Firestore
|
|
1027
|
+
// users doc — so just skip setting it on the Auth record for relative
|
|
1028
|
+
// paths instead of failing the entire seed row.
|
|
1029
|
+
if (photoURL && typeof photoURL === "string" && /^https?:\/\//.test(photoURL)) {
|
|
1030
|
+
authUserData.photoURL = photoURL;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (!userExists) {
|
|
1034
|
+
await resolveAuthConflicts(uid, authUserData);
|
|
1035
|
+
await auth.createUser({ uid, ...authUserData, password: "TempPass123!" });
|
|
1036
|
+
}
|
|
1037
|
+
if (u.role && u.role !== "user") {
|
|
1038
|
+
await auth.setCustomUserClaims(uid, { role: u.role });
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Both arguments read the PLAINTEXT `plain`: indices must be hashed from
|
|
1042
|
+
// the source text, and encryption must not see an already-hashed value.
|
|
1043
|
+
// The previous form relied on ordering alone (indices first, THEN
|
|
1044
|
+
// encrypt) — correct, but one line-swap away from spreading plaintext
|
|
1045
|
+
// back over ciphertext, which is why `addPiiIndices` no longer exists.
|
|
1046
|
+
/*
|
|
1047
|
+
* 🛑 Provenance is stamped here too, and `users` is the collection the
|
|
1048
|
+
* whole scheme exists for.
|
|
1049
|
+
*
|
|
1050
|
+
* It had no permanence marker of any kind, so a seeded persona could not
|
|
1051
|
+
* be told apart from a real signup — and a seeded user is the most
|
|
1052
|
+
* expensive thing to delete wrongly: it owns an **Auth record**, a
|
|
1053
|
+
* `userSlugs` index entry, addresses, a cart, orders, bids and reviews,
|
|
1054
|
+
* and `users` sits in the tester harness's PRESERVE tier precisely
|
|
1055
|
+
* because a wrong sweep there is the only permanent damage a run can do.
|
|
1056
|
+
*
|
|
1057
|
+
* Stamped before the PII pass so `plain` carries it: the indices are
|
|
1058
|
+
* hashed from plaintext and the encryption must not see a hashed value,
|
|
1059
|
+
* so nothing may be added to `docData` after that pair runs.
|
|
1060
|
+
*/
|
|
1061
|
+
const plain = stripUndefined(stampSeedPurpose("users", { ...u }));
|
|
1062
|
+
let docData = {
|
|
1063
|
+
...encryptPiiFields(plain, [...USER_PII_FIELDS]),
|
|
1064
|
+
...piiIndicesFor(plain, USER_PII_INDEX_MAP),
|
|
1065
|
+
};
|
|
1066
|
+
if (docData.payoutDetails) docData.payoutDetails = encryptPayoutDetails(docData.payoutDetails);
|
|
1067
|
+
if (docData.shippingConfig) docData.shippingConfig = encryptShippingConfig(docData.shippingConfig);
|
|
1068
|
+
await docRef.set(docData, { merge: true });
|
|
1069
|
+
stats.created++;
|
|
1070
|
+
if (VERBOSE) console.log(` + ${uid}`);
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
console.error(` ✗ user ${u.uid}: ${err.message}`);
|
|
1073
|
+
stats.errors++;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function loadSubcollection(seed, parentColl, parentKey, subColl, idKey, piiFields, stats) {
|
|
1079
|
+
for (const d of seed) {
|
|
1080
|
+
try {
|
|
1081
|
+
const parent = d[parentKey];
|
|
1082
|
+
const id = d[idKey];
|
|
1083
|
+
if (!parent || !id) { stats.errors++; continue; }
|
|
1084
|
+
const { [parentKey]: _p, [idKey]: _i, ...data } = d;
|
|
1085
|
+
const docRef = db.collection(parentColl).doc(parent).collection(subColl).doc(id);
|
|
1086
|
+
/*
|
|
1087
|
+
* Subcollection rows inherit the SUBCOLLECTION's purpose, not the
|
|
1088
|
+
* parent's — `subColl` is the key in `SEED_COLLECTION_PURPOSE`. A
|
|
1089
|
+
* `couponUsage` row under a permanent user is still its own record, and
|
|
1090
|
+
* conflating the two would make a parent's classification silently
|
|
1091
|
+
* govern children it knows nothing about.
|
|
1092
|
+
*/
|
|
1093
|
+
const stamped = stripUndefined(stampSeedPurpose(subColl, data));
|
|
1094
|
+
const payload = piiFields ? encryptPiiFields(stamped, [...piiFields]) : stamped;
|
|
1095
|
+
await docRef.set(payload, { merge: true });
|
|
1096
|
+
stats.created++;
|
|
1097
|
+
} catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function loadOneDocPerUser(seed, coll, stats) {
|
|
1102
|
+
for (const d of seed) {
|
|
1103
|
+
try {
|
|
1104
|
+
const { id, userId, items, updatedAt } = d;
|
|
1105
|
+
if (!id || !userId) { stats.errors++; continue; }
|
|
1106
|
+
/*
|
|
1107
|
+
* 🛑 `merge: false` — this loader REPLACES the document, which is correct
|
|
1108
|
+
* for a wishlist or a history list (the array IS the record), and is
|
|
1109
|
+
* exactly why the stamp has to be inside the payload rather than added
|
|
1110
|
+
* by a later merge-write that would be wiped on the next load.
|
|
1111
|
+
*
|
|
1112
|
+
* `coll` is the real Firestore collection name here rather than the seed
|
|
1113
|
+
* key, and for `wishlists`/`history` the two are identical — the only two
|
|
1114
|
+
* callers. `resolveSeedPurpose` falls back to `permanent` if that ever
|
|
1115
|
+
* stops being true, which is the safe direction.
|
|
1116
|
+
*/
|
|
1117
|
+
await db.collection(coll).doc(id).set(
|
|
1118
|
+
stripUndefined(
|
|
1119
|
+
stampSeedPurpose(coll, {
|
|
1120
|
+
userId,
|
|
1121
|
+
items: items ?? [],
|
|
1122
|
+
updatedAt: updatedAt ?? new Date(),
|
|
1123
|
+
}),
|
|
1124
|
+
),
|
|
1125
|
+
{ merge: false },
|
|
1126
|
+
);
|
|
1127
|
+
stats.created++;
|
|
1128
|
+
} catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Collections whose rows a human may hand-correct, with the correction
|
|
1134
|
+
* protected from the next reseed.
|
|
1135
|
+
*
|
|
1136
|
+
* 🛑 AN EXPLICIT LIST, not "every collection". Protection costs one query per
|
|
1137
|
+
* load, and more importantly it is only meaningful for GENERATED data: a row
|
|
1138
|
+
* an admin authored from scratch (a coupon, an event) is not competing with a
|
|
1139
|
+
* generator for ownership of its fields, so there is nothing to protect it
|
|
1140
|
+
* from. Adding a collection here is a statement that a generator owns the row
|
|
1141
|
+
* and a human may override named fields of it.
|
|
1142
|
+
*/
|
|
1143
|
+
/**
|
|
1144
|
+
* Drop the keys a human owns from one payload.
|
|
1145
|
+
*
|
|
1146
|
+
* 🛑 A LOCAL copy of `stripAdminOwnedFields` from
|
|
1147
|
+
* `appkit/src/features/parts/schemas/admin-edit.ts`, and the duplication is
|
|
1148
|
+
* deliberate: this CLI must run without a built appkit. Importing it from
|
|
1149
|
+
* `dist` would reintroduce exactly the build dependency the catalogue
|
|
1150
|
+
* generator just shed, and would make a reseed impossible under a memory cap.
|
|
1151
|
+
* The function is five lines; the dependency would not be.
|
|
1152
|
+
*/
|
|
1153
|
+
function stripAdminOwned(payload, ownedFields) {
|
|
1154
|
+
if (!ownedFields?.length) return payload;
|
|
1155
|
+
const out = {};
|
|
1156
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
1157
|
+
if (ownedFields.includes(k)) continue;
|
|
1158
|
+
out[k] = v;
|
|
1159
|
+
}
|
|
1160
|
+
return out;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const ADMIN_EDIT_PROTECTED = new Set(["partGlossary", "beys"]);
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Fields a seed may CREATE but must never OVERWRITE — derived counters.
|
|
1167
|
+
*
|
|
1168
|
+
* 🛑 This exists because seeding silently zeroed the whole taxonomy. Every row
|
|
1169
|
+
* in `categories-seed-data.ts` hard-codes
|
|
1170
|
+
* `metrics: { productCount: 0, totalProductCount: 0, … }`, and `seed-cli`
|
|
1171
|
+
* merge-writes — a merge overwrites any key PRESENT in the payload. So
|
|
1172
|
+
* `appkit-seed load --collections categories` set every counter on every
|
|
1173
|
+
* category to zero.
|
|
1174
|
+
*
|
|
1175
|
+
* Measured 2026-10-21: after a demo purge, all 17 categories that should have
|
|
1176
|
+
* carried stock read `total=0` while their products were filed perfectly --
|
|
1177
|
+
* `category-beyblade-x` stored 0 against an actual 14. Every parent looked
|
|
1178
|
+
* empty. Reseeding to add four bundles then zeroed them a SECOND time,
|
|
1179
|
+
* minutes after they had been repaired.
|
|
1180
|
+
*
|
|
1181
|
+
* Nothing errors, because a wrong number is just a number. The symptom reaches
|
|
1182
|
+
* only a human looking at a page that says a category has nothing in it.
|
|
1183
|
+
*
|
|
1184
|
+
* 🛑 CREATE but not OVERWRITE, not "always strip" — `CategoryDocument.metrics`
|
|
1185
|
+
* is a REQUIRED field with three unguarded readers (`CategoryTableColumns`,
|
|
1186
|
+
* `CategoryTree`, `isFeaturedEligible`). Stripping it unconditionally would
|
|
1187
|
+
* leave a freshly seeded document violating its own type until the reconciler
|
|
1188
|
+
* first ran. Establishing zeros on create is correct; overwriting live counts
|
|
1189
|
+
* is not.
|
|
1190
|
+
*
|
|
1191
|
+
* The authority for these fields is `runCountersReconcile` plus the live
|
|
1192
|
+
* write triggers — see the `countersReconcileNow` Function for the on-demand
|
|
1193
|
+
* run that belongs immediately after a seed.
|
|
1194
|
+
*/
|
|
1195
|
+
const DERIVED_COUNTER_FIELDS = {
|
|
1196
|
+
categories: ["metrics"],
|
|
1197
|
+
stores: ["stats"],
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* The ids a collection already holds, when it has derived counters to protect.
|
|
1202
|
+
*
|
|
1203
|
+
* 🛑 A `.select()` projection — ids only, no document bodies — so this costs
|
|
1204
|
+
* reads but transfers almost nothing. It runs ONLY for the two collections in
|
|
1205
|
+
* `DERIVED_COUNTER_FIELDS`, and only when that collection is actually being
|
|
1206
|
+
* loaded; every other collection pays nothing.
|
|
1207
|
+
*
|
|
1208
|
+
* Fails OPEN to an empty set: if the existence probe itself fails we would
|
|
1209
|
+
* rather write the seed's zeros (recoverable by one reconcile) than abort a
|
|
1210
|
+
* load. The reconciler is the backstop either way.
|
|
1211
|
+
*/
|
|
1212
|
+
async function fetchExistingIds(db, collection) {
|
|
1213
|
+
const fields = DERIVED_COUNTER_FIELDS[collection];
|
|
1214
|
+
if (!fields) return null;
|
|
1215
|
+
try {
|
|
1216
|
+
const snap = await db.collection(collection).select().get();
|
|
1217
|
+
return new Set(snap.docs.map((d) => d.id));
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
console.log(
|
|
1220
|
+
` ⚠ could not probe existing ${collection} ids (${err?.message ?? err}) — derived counters may be reset; run countersReconcileNow after this load`,
|
|
1221
|
+
);
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** Drop derived-counter keys when the document already exists. */
|
|
1227
|
+
function stripDerivedCounters(collection, payload, docId, existingIds) {
|
|
1228
|
+
const fields = DERIVED_COUNTER_FIELDS[collection];
|
|
1229
|
+
if (!fields || !existingIds || !existingIds.has(docId)) return payload;
|
|
1230
|
+
const out = {};
|
|
1231
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
1232
|
+
if (fields.includes(k)) continue;
|
|
1233
|
+
out[k] = v;
|
|
1234
|
+
}
|
|
1235
|
+
return out;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* The fields a human owns, per document id, for one collection.
|
|
1240
|
+
*
|
|
1241
|
+
* 🛑 ONE INDEXED EQUALITY, not a read per document. The alternative is
|
|
1242
|
+
* fetching all 2,488 parts and 1,988 beys to discover the handful that are
|
|
1243
|
+
* protected — 4,476 reads against a 50,000/day free tier, every load, to find
|
|
1244
|
+
* what is usually a dozen rows.
|
|
1245
|
+
*
|
|
1246
|
+
* 🛑 It must be an EQUALITY on a boolean, not an `orderBy` or `!=` on
|
|
1247
|
+
* `adminEditedFields`. Both of those silently exclude every document that
|
|
1248
|
+
* LACKS the field, which is nearly all of them — the trap that made a store's
|
|
1249
|
+
* whole review set return zero (Root Cause #100) and the one `isTestData`
|
|
1250
|
+
* warning in the Known TS Patterns table.
|
|
1251
|
+
*
|
|
1252
|
+
* Fails OPEN to an empty map: if this query errors the load proceeds
|
|
1253
|
+
* unprotected, which loses a hand-edit. The alternative — aborting the load —
|
|
1254
|
+
* loses the entire reseed. A warning names the collection either way.
|
|
1255
|
+
*/
|
|
1256
|
+
async function fetchAdminOwnedFields(colName, firestoreCollection) {
|
|
1257
|
+
if (!ADMIN_EDIT_PROTECTED.has(colName)) return new Map();
|
|
1258
|
+
try {
|
|
1259
|
+
const snap = await db
|
|
1260
|
+
.collection(firestoreCollection)
|
|
1261
|
+
.where("adminEdited", "==", true)
|
|
1262
|
+
.select("adminEditedFields")
|
|
1263
|
+
.get();
|
|
1264
|
+
const map = new Map();
|
|
1265
|
+
for (const d of snap.docs) {
|
|
1266
|
+
const fields = d.get("adminEditedFields");
|
|
1267
|
+
if (Array.isArray(fields) && fields.length > 0) map.set(d.id, fields);
|
|
1268
|
+
}
|
|
1269
|
+
if (map.size > 0) {
|
|
1270
|
+
console.log(
|
|
1271
|
+
` ${map.size} row(s) carry admin-owned fields — those keys will not be overwritten`,
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
return map;
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
console.warn(
|
|
1277
|
+
` ⚠ could not read admin-owned fields for ${colName} (${err?.message ?? err}).
|
|
1278
|
+
` +
|
|
1279
|
+
` Proceeding WITHOUT protection — a hand-correction in this collection may be lost.`,
|
|
1280
|
+
);
|
|
1281
|
+
return new Map();
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function loadGeneric(colName, seed, firestoreCollection, stats) {
|
|
1286
|
+
const adminOwned = await fetchAdminOwnedFields(colName, firestoreCollection);
|
|
1287
|
+
if (REPLACE_ON_LOAD.has(colName)) {
|
|
1288
|
+
const removed = await purgeCollection(firestoreCollection);
|
|
1289
|
+
if (VERBOSE) console.log(` purged ${removed} pre-existing docs`);
|
|
1290
|
+
}
|
|
1291
|
+
/*
|
|
1292
|
+
* 🛑 Probed AFTER any replace-purge, never before. On a `REPLACE_ON_LOAD`
|
|
1293
|
+
* collection the purge removes every document, so an id set taken earlier
|
|
1294
|
+
* would claim rows exist that no longer do — and their derived counters
|
|
1295
|
+
* would then be stripped from a payload that is now a CREATE, leaving the
|
|
1296
|
+
* field absent on a required type. Probing here means a purged collection
|
|
1297
|
+
* correctly reports nothing existing.
|
|
1298
|
+
*/
|
|
1299
|
+
const existingIds = await fetchExistingIds(db, colName);
|
|
1300
|
+
if (existingIds && existingIds.size > 0) {
|
|
1301
|
+
console.log(
|
|
1302
|
+
` • ${colName}: preserving live ${DERIVED_COUNTER_FIELDS[colName].join("/")} on ${existingIds.size} existing doc(s)`,
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
const items = [];
|
|
1306
|
+
for (const docData of seed) {
|
|
1307
|
+
let { id, ...data } = docData;
|
|
1308
|
+
if (!id && colName === "faqs") {
|
|
1309
|
+
id = generateFAQId({ category: docData.category, question: docData.question });
|
|
1310
|
+
if (!id) { stats.errors++; continue; }
|
|
1311
|
+
} else if (!id) { stats.errors++; continue; }
|
|
1312
|
+
// `id` already carries the project's mandatory slug prefix (blog-, event-,
|
|
1313
|
+
// faq-, etc. — see CLAUDE.md's Slug Prefix System). Preferring the bare
|
|
1314
|
+
// `slug` field here (as this used to) wrote every blogPosts/events doc
|
|
1315
|
+
// under its unprefixed slug instead, e.g. "auction-feature-launch"
|
|
1316
|
+
// instead of "blog-auction-feature-launch" — found 2026-08-17 auditing
|
|
1317
|
+
// real Firestore doc IDs after a full reseed.
|
|
1318
|
+
const docId = id ?? docData.slug;
|
|
1319
|
+
if (!data.slug) data.slug = docData.slug ?? docId;
|
|
1320
|
+
items.push({
|
|
1321
|
+
ref: db.collection(firestoreCollection).doc(docId),
|
|
1322
|
+
/*
|
|
1323
|
+
* 🛑 Stamp provenance BEFORE `stripUndefined`, not after.
|
|
1324
|
+
*
|
|
1325
|
+
* `seedPurposeFields` deliberately OMITS `isTestData` for a `permanent`
|
|
1326
|
+
* row rather than writing `false` — so stamping afterwards would be
|
|
1327
|
+
* indistinguishable from not stamping, and stripping afterwards is what
|
|
1328
|
+
* keeps the absence genuine instead of writing `undefined` keys.
|
|
1329
|
+
*
|
|
1330
|
+
* One call, one choke point: every one of the ~2,500 documents this
|
|
1331
|
+
* loader writes gets a `seedPurpose`, so the purge is a query rather than
|
|
1332
|
+
* a heuristic, and `users`/`orders` are reachable by it for the first
|
|
1333
|
+
* time.
|
|
1334
|
+
*/
|
|
1335
|
+
/*
|
|
1336
|
+
* 🛑 `docId` is passed EXPLICITLY because this loader destructures `id`
|
|
1337
|
+
* out (`let { id, ...data } = docData`), so the row it hands over has no
|
|
1338
|
+
* id for the per-row overrides to match on. Measured without it:
|
|
1339
|
+
* `store-letitrip-official` loaded as `test` — the platform's own store,
|
|
1340
|
+
* which is one of exactly two rows the keep-list exists to protect.
|
|
1341
|
+
*/
|
|
1342
|
+
/*
|
|
1343
|
+
* 🛑 Admin-owned keys dropped LAST, after every other transform.
|
|
1344
|
+
*
|
|
1345
|
+
* Stripping earlier would let `stampSeedPurpose` or `encryptSeedPii`
|
|
1346
|
+
* re-add a key a human owns — both of them write fields — and the whole
|
|
1347
|
+
* point is that what reaches Firestore carries none of them. A
|
|
1348
|
+
* merge-write leaves an absent key exactly as it was, which is how the
|
|
1349
|
+
* correction survives.
|
|
1350
|
+
*/
|
|
1351
|
+
/*
|
|
1352
|
+
* 🛑 Derived counters dropped on an EXISTING document, after everything
|
|
1353
|
+
* else, for the same reason admin-owned keys are: a merge-write leaves
|
|
1354
|
+
* an absent key exactly as it was, which is what preserves the live
|
|
1355
|
+
* value. Stripping earlier would let a later transform re-add it.
|
|
1356
|
+
*/
|
|
1357
|
+
data: stripDerivedCounters(
|
|
1358
|
+
colName,
|
|
1359
|
+
stripAdminOwned(
|
|
1360
|
+
encryptSeedPii(colName, stripUndefined(stampSeedPurpose(colName, data, docId))),
|
|
1361
|
+
adminOwned.get(docId),
|
|
1362
|
+
),
|
|
1363
|
+
docId,
|
|
1364
|
+
existingIds,
|
|
1365
|
+
),
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
/*
|
|
1369
|
+
* 🛑 Chunk by INDEX ENTRIES, not just by operation count.
|
|
1370
|
+
*
|
|
1371
|
+
* This loop capped at 500 operations and nothing else. That was invisible for
|
|
1372
|
+
* years because every seeded collection had small documents; B5's 416
|
|
1373
|
+
* categories are the first that do not. Measured 2026-10-11: one 416-op
|
|
1374
|
+
* commit failed with `INVALID_ARGUMENT: Transaction too big` and wrote
|
|
1375
|
+
* **0 of 416** — not a partial load, nothing at all.
|
|
1376
|
+
*
|
|
1377
|
+
* 🛑 The failure mode is what makes this worth a real fix rather than a
|
|
1378
|
+
* retry: `✓ Load complete — created 2097, errors 1` reads as a near-complete
|
|
1379
|
+
* run, and the ONE missing collection was the taxonomy that every product's
|
|
1380
|
+
* `categorySlugs` chain points into. A purge-then-load left 70 products
|
|
1381
|
+
* referencing ids that did not exist, with a cheerful summary line.
|
|
1382
|
+
*
|
|
1383
|
+
* ## What actually binds, measured by bisection rather than assumed
|
|
1384
|
+
*
|
|
1385
|
+
* My first fix capped PAYLOAD BYTES, on the theory that these documents are
|
|
1386
|
+
* large. They are not: 416 categories are **1.51 MiB total**, mean 3.7 KiB,
|
|
1387
|
+
* largest 7.9 KiB — nowhere near the 10 MiB request limit, and the byte cap
|
|
1388
|
+
* never once triggered. The commit failed anyway.
|
|
1389
|
+
*
|
|
1390
|
+
* Bisecting the op count found the real boundary, and counting array elements
|
|
1391
|
+
* explains it exactly:
|
|
1392
|
+
*
|
|
1393
|
+
* 416 ops -> 60,787 array elements -> FAILS
|
|
1394
|
+
* 250 ops -> 42,912 -> FAILS
|
|
1395
|
+
* 100 ops -> 17,578 -> works
|
|
1396
|
+
* 50 ops -> 8,791 -> works
|
|
1397
|
+
*
|
|
1398
|
+
* The threshold sits right at **40,000**, which is Firestore's documented
|
|
1399
|
+
* maximum index entries — applied per COMMIT here, not per document. Every
|
|
1400
|
+
* element of an indexed array is an index entry, and a category carries ~146
|
|
1401
|
+
* of them (`searchTxt` alone is ~150 tokens, plus `parentIds`, `ancestors`,
|
|
1402
|
+
* `categoryNames`, `faqs`, `highlights`, `descriptionTemplates`).
|
|
1403
|
+
*
|
|
1404
|
+
* So the cap is on array elements at **20,000** — half the real limit, the
|
|
1405
|
+
* same margin the byte cap keeps — and it is kept alongside the op and byte
|
|
1406
|
+
* caps rather than replacing them, because all three are real Firestore
|
|
1407
|
+
* limits and only one of them has bitten so far.
|
|
1408
|
+
*
|
|
1409
|
+
* 🛑 Do NOT "simplify" this back to a lower fixed op count. 100 would work
|
|
1410
|
+
* for categories and silently triple the commit count for `faqs` and the
|
|
1411
|
+
* 1,337 tester checklist items, which carry almost no arrays. The cap has to
|
|
1412
|
+
* key on the thing that varies.
|
|
1413
|
+
*/
|
|
1414
|
+
const MAX_OPS = Number(process.env.SEED_BATCH_OPS ?? 500);
|
|
1415
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
1416
|
+
const MAX_INDEX_ENTRIES = Number(process.env.SEED_BATCH_INDEX_ENTRIES ?? 20_000);
|
|
1417
|
+
|
|
1418
|
+
/** Array elements, recursively — a proxy for index entries written. */
|
|
1419
|
+
const countArrayElements = (v) => {
|
|
1420
|
+
if (Array.isArray(v)) return v.length + v.reduce((s, x) => s + countArrayElements(x), 0);
|
|
1421
|
+
if (v && typeof v === "object" && !(v instanceof Date)) {
|
|
1422
|
+
return Object.values(v).reduce((s, x) => s + countArrayElements(x), 0);
|
|
1423
|
+
}
|
|
1424
|
+
return 0;
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
let chunk = [];
|
|
1428
|
+
let chunkBytes = 0;
|
|
1429
|
+
let chunkEntries = 0;
|
|
1430
|
+
const flush = async () => {
|
|
1431
|
+
if (chunk.length === 0) return;
|
|
1432
|
+
const batch = db.batch();
|
|
1433
|
+
for (const { ref, data } of chunk) batch.set(ref, data, { merge: true });
|
|
1434
|
+
await batch.commit();
|
|
1435
|
+
stats.created += chunk.length;
|
|
1436
|
+
chunk = [];
|
|
1437
|
+
chunkBytes = 0;
|
|
1438
|
+
chunkEntries = 0;
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
for (const item of items) {
|
|
1442
|
+
let size = 0;
|
|
1443
|
+
try {
|
|
1444
|
+
size = Buffer.byteLength(JSON.stringify(item.data), "utf8");
|
|
1445
|
+
} catch {
|
|
1446
|
+
/*
|
|
1447
|
+
* A payload that cannot be serialised cannot be measured. Treat it as a
|
|
1448
|
+
* whole chunk's worth rather than as zero — guessing small is how a
|
|
1449
|
+
* commit silently grows past a limit again, and a document that resists
|
|
1450
|
+
* measurement is the one most likely to be large.
|
|
1451
|
+
*/
|
|
1452
|
+
size = MAX_BYTES;
|
|
1453
|
+
}
|
|
1454
|
+
const entries = countArrayElements(item.data);
|
|
1455
|
+
/*
|
|
1456
|
+
* A single document over any cap still has to be written, and alone: a
|
|
1457
|
+
* one-document commit is the smallest thing committable, so if Firestore
|
|
1458
|
+
* rejects THAT the document itself is the problem and the error says so,
|
|
1459
|
+
* rather than the failure being absorbed into a chunking bug.
|
|
1460
|
+
*/
|
|
1461
|
+
if (
|
|
1462
|
+
chunk.length > 0 &&
|
|
1463
|
+
(chunk.length >= MAX_OPS ||
|
|
1464
|
+
chunkBytes + size > MAX_BYTES ||
|
|
1465
|
+
chunkEntries + entries > MAX_INDEX_ENTRIES)
|
|
1466
|
+
) {
|
|
1467
|
+
await flush();
|
|
1468
|
+
}
|
|
1469
|
+
chunk.push(item);
|
|
1470
|
+
chunkBytes += size;
|
|
1471
|
+
chunkEntries += entries;
|
|
1472
|
+
}
|
|
1473
|
+
await flush();
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// ---------------------------------------------------------------------------
|
|
1477
|
+
// Load
|
|
1478
|
+
// ---------------------------------------------------------------------------
|
|
1479
|
+
async function cmdLoad() {
|
|
1480
|
+
if (DRY_RUN) return cmdDryRun("load");
|
|
1481
|
+
|
|
1482
|
+
const stats = { created: 0, deleted: 0, skipped: 0, errors: 0 };
|
|
1483
|
+
for (const colName of collectionsToProcess) {
|
|
1484
|
+
const seed = SEED_DATA_MAP[colName];
|
|
1485
|
+
if (!seed || seed.length === 0) { console.log(`▌ ${colName} — no seed data, skipping`); continue; }
|
|
1486
|
+
console.log(`▌ ${colName} (${seed.length} docs)`);
|
|
1487
|
+
|
|
1488
|
+
try {
|
|
1489
|
+
if (colName === "users") {
|
|
1490
|
+
await loadUsers(seed, stats);
|
|
1491
|
+
} else if (colName === "addresses") {
|
|
1492
|
+
// SB-UNI-A (2026-05-13) unified addresses into one top-level collection
|
|
1493
|
+
// with an ownerType/ownerId discriminator, replacing the two prior
|
|
1494
|
+
// subcollections (users/{uid}/addresses, stores/{slug}/addresses) this
|
|
1495
|
+
// loader used to write to. addressesSeedData already carries
|
|
1496
|
+
// ownerType/ownerId directly — write it straight to the top-level
|
|
1497
|
+
// collection instead of nesting under USER_COLLECTION's old subcollection.
|
|
1498
|
+
for (const d of seed) {
|
|
1499
|
+
try {
|
|
1500
|
+
const { id, ownerType, ownerId, ...data } = d;
|
|
1501
|
+
if (!id || !ownerType || !ownerId) { stats.errors++; continue; }
|
|
1502
|
+
const payload = encryptPiiFields(
|
|
1503
|
+
stripUndefined(stampSeedPurpose("addresses", { ownerType, ownerId, ...data }, id)),
|
|
1504
|
+
[...ADDRESS_PII_FIELDS],
|
|
1505
|
+
);
|
|
1506
|
+
await db.collection("addresses").doc(id).set(payload, { merge: true });
|
|
1507
|
+
stats.created++;
|
|
1508
|
+
} catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
|
|
1509
|
+
}
|
|
1510
|
+
} else if (colName === "storeAddresses") {
|
|
1511
|
+
// storeAddressesSeedData predates the SB-UNI-A unification and still
|
|
1512
|
+
// carries the old storeSlug field instead of ownerType/ownerId — derive
|
|
1513
|
+
// the unified shape at load time rather than writing to the deleted
|
|
1514
|
+
// stores/{slug}/addresses subcollection.
|
|
1515
|
+
for (const d of seed) {
|
|
1516
|
+
try {
|
|
1517
|
+
const { id, storeSlug, ...data } = d;
|
|
1518
|
+
if (!id || !storeSlug) { stats.errors++; continue; }
|
|
1519
|
+
/*
|
|
1520
|
+
* Stamped under the SEED KEY `storeAddresses`, not the target
|
|
1521
|
+
* collection `addresses`. Both resolve `test`, so nothing differs
|
|
1522
|
+
* today — but the purpose table is keyed on the seed key, and
|
|
1523
|
+
* stamping under the write target would make the two disagree the
|
|
1524
|
+
* day one of them changes.
|
|
1525
|
+
*/
|
|
1526
|
+
const payload = encryptPiiFields(
|
|
1527
|
+
stripUndefined(
|
|
1528
|
+
stampSeedPurpose("storeAddresses", { ownerType: "store", ownerId: storeSlug, ...data }, id),
|
|
1529
|
+
),
|
|
1530
|
+
[...ADDRESS_PII_FIELDS],
|
|
1531
|
+
);
|
|
1532
|
+
await db.collection("addresses").doc(id).set(payload, { merge: true });
|
|
1533
|
+
stats.created++;
|
|
1534
|
+
} catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
|
|
1535
|
+
}
|
|
1536
|
+
} else if (colName === "couponUsage") {
|
|
1537
|
+
// shape: { userId, couponId, ... } — no PII
|
|
1538
|
+
for (const d of seed) {
|
|
1539
|
+
try {
|
|
1540
|
+
const { userId, couponId, ...data } = d;
|
|
1541
|
+
if (!userId || !couponId) { stats.errors++; continue; }
|
|
1542
|
+
await db.collection(USER_COLLECTION).doc(userId).collection("couponUsage").doc(couponId)
|
|
1543
|
+
.set(
|
|
1544
|
+
stripUndefined(stampSeedPurpose("couponUsage", { couponId, ...data }, couponId)),
|
|
1545
|
+
{ merge: true },
|
|
1546
|
+
);
|
|
1547
|
+
stats.created++;
|
|
1548
|
+
} catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
|
|
1549
|
+
}
|
|
1550
|
+
} else if (colName === "wishlists") {
|
|
1551
|
+
await loadOneDocPerUser(seed, WISHLIST_COLLECTION, stats);
|
|
1552
|
+
} else if (colName === "history") {
|
|
1553
|
+
await loadOneDocPerUser(seed, HISTORY_COLLECTION, stats);
|
|
1554
|
+
} else if (colName === "siteSettings") {
|
|
1555
|
+
const settings = seed[0];
|
|
1556
|
+
if (settings) {
|
|
1557
|
+
await db.collection(SITE_SETTINGS_COLLECTION).doc("global").set(
|
|
1558
|
+
stripUndefined(stampSeedPurpose("siteSettings", settings, "global")), { merge: true });
|
|
1559
|
+
stats.created++;
|
|
1560
|
+
}
|
|
1561
|
+
} else {
|
|
1562
|
+
await loadGeneric(colName, seed, COLLECTION_MAP[colName], stats);
|
|
1563
|
+
}
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
console.error(`✗ ${colName}: ${err.message}`);
|
|
1566
|
+
stats.errors++;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
console.log(`\n✓ Load complete — created ${stats.created}, errors ${stats.errors}\n`);
|
|
1571
|
+
process.exit(stats.errors > 0 ? 1 : 0);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// ---------------------------------------------------------------------------
|
|
1575
|
+
// Delete
|
|
1576
|
+
// ---------------------------------------------------------------------------
|
|
1577
|
+
async function cmdDelete() {
|
|
1578
|
+
if (DRY_RUN) return cmdDryRun("delete");
|
|
1579
|
+
|
|
1580
|
+
if (!(await confirm(`This will purge ${collectionsToProcess.length} collection(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'. Continue?`))) {
|
|
1581
|
+
console.log("Aborted."); process.exit(0);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const PROTECTED_USER_UIDS = new Set(["user-admin-letitrip"]);
|
|
1585
|
+
const stats = { deleted: 0, skipped: 0, errors: 0 };
|
|
1586
|
+
|
|
1587
|
+
for (const colName of collectionsToProcess) {
|
|
1588
|
+
console.log(`▌ ${colName}`);
|
|
1589
|
+
const seed = SEED_DATA_MAP[colName] || [];
|
|
1590
|
+
try {
|
|
1591
|
+
if (colName === "users") {
|
|
1592
|
+
for (const u of seed) {
|
|
1593
|
+
if (PROTECTED_USER_UIDS.has(u.uid)) { stats.skipped++; continue; }
|
|
1594
|
+
try { await auth.deleteUser(u.uid); }
|
|
1595
|
+
catch (e) { if (e?.code !== "auth/user-not-found") console.error(` auth: ${e.message}`); }
|
|
1596
|
+
const ref = db.collection(USER_COLLECTION).doc(u.uid);
|
|
1597
|
+
if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
|
|
1598
|
+
}
|
|
1599
|
+
} else if (colName === "addresses" || colName === "storeAddresses") {
|
|
1600
|
+
// SB-UNI-A (2026-05-13): both user and store addresses live in the
|
|
1601
|
+
// single top-level `addresses` collection now, keyed by `id` — the
|
|
1602
|
+
// old per-parent subcollection paths (users/{uid}/addresses,
|
|
1603
|
+
// stores/{slug}/addresses) no longer exist, so deleting from them was
|
|
1604
|
+
// always a silent no-op. Delete by doc id straight from `addresses`.
|
|
1605
|
+
for (const d of seed) {
|
|
1606
|
+
if (!d.id) { stats.skipped++; continue; }
|
|
1607
|
+
const ref = db.collection("addresses").doc(d.id);
|
|
1608
|
+
if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
|
|
1609
|
+
else stats.skipped++;
|
|
1610
|
+
}
|
|
1611
|
+
} else if (colName === "couponUsage") {
|
|
1612
|
+
const userIds = [...new Set(seed.map((d) => d.userId).filter(Boolean))];
|
|
1613
|
+
for (const uid of userIds) {
|
|
1614
|
+
stats.deleted += await purgeCollection(`${USER_COLLECTION}/${uid}/couponUsage`);
|
|
1615
|
+
}
|
|
1616
|
+
} else if (colName === "siteSettings") {
|
|
1617
|
+
const ref = db.collection(SITE_SETTINGS_COLLECTION).doc("global");
|
|
1618
|
+
if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; } else stats.skipped++;
|
|
1619
|
+
} else {
|
|
1620
|
+
stats.deleted += await purgeCollection(COLLECTION_MAP[colName]);
|
|
1621
|
+
}
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
console.error(`✗ ${colName}: ${err.message}`);
|
|
1624
|
+
stats.errors++;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
console.log(`\n✓ Delete complete — removed ${stats.deleted}, errors ${stats.errors}\n`);
|
|
1629
|
+
process.exit(stats.errors > 0 ? 1 : 0);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// ---------------------------------------------------------------------------
|
|
1633
|
+
// Dry run
|
|
1634
|
+
// ---------------------------------------------------------------------------
|
|
1635
|
+
async function cmdDryRun(intendedAction) {
|
|
1636
|
+
console.log(`\nDry run (${intendedAction}) — no writes will happen.\n`);
|
|
1637
|
+
console.log(`${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)} ${"would " + (intendedAction === "load" ? "create" : "delete")}`);
|
|
1638
|
+
console.log("─".repeat(56));
|
|
1639
|
+
let totalCreate = 0, totalDelete = 0;
|
|
1640
|
+
for (const c of collectionsToProcess) {
|
|
1641
|
+
const seedCount = (SEED_DATA_MAP[c] || []).length;
|
|
1642
|
+
const existing = await countExisting(c).catch(() => 0);
|
|
1643
|
+
const wouldCreate = intendedAction === "load" ? Math.max(seedCount - existing, 0) : 0;
|
|
1644
|
+
const wouldDelete = intendedAction === "delete" ? existing : 0;
|
|
1645
|
+
totalCreate += wouldCreate; totalDelete += wouldDelete;
|
|
1646
|
+
console.log(` ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)} ${String(intendedAction === "load" ? wouldCreate : wouldDelete).padStart(12)}`);
|
|
1647
|
+
}
|
|
1648
|
+
console.log("─".repeat(56));
|
|
1649
|
+
console.log(`Total would ${intendedAction === "load" ? "create" : "delete"}: ${intendedAction === "load" ? totalCreate : totalDelete}\n`);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// ---------------------------------------------------------------------------
|
|
1653
|
+
// Main
|
|
1654
|
+
/**
|
|
1655
|
+
* `appkit-seed purge` — delete every `seedPurpose: "test"` document and NOTHING
|
|
1656
|
+
* else.
|
|
1657
|
+
*
|
|
1658
|
+
* ## Why this is not `delete`
|
|
1659
|
+
*
|
|
1660
|
+
* `delete` empties whole COLLECTIONS. That is the right tool for a full reset
|
|
1661
|
+
* and the wrong one for "clear the test data": it would take the 85 authored
|
|
1662
|
+
* FAQs, the 416-node taxonomy every product's `categorySlugs` chain points
|
|
1663
|
+
* into, the real HSN tax codes, the 1,337 tester cases and the AES-encrypted
|
|
1664
|
+
* site credentials with it.
|
|
1665
|
+
*
|
|
1666
|
+
* This is a QUERY over a declaration — `where("seedPurpose","==","test")` —
|
|
1667
|
+
* which gives four properties no heuristic can:
|
|
1668
|
+
*
|
|
1669
|
+
* 1. **A row the seeder never wrote is unreachable.** Organic documents (a
|
|
1670
|
+
* real buyer's cart, a runtime notification, a real signup) carry no
|
|
1671
|
+
* `seedPurpose` at all, so an equality on it cannot match them. Measured
|
|
1672
|
+
* live: 64 of 84 users are organic. That is the single most important
|
|
1673
|
+
* safety property here, and it comes from the field's ABSENCE rather than
|
|
1674
|
+
* from a list someone has to maintain.
|
|
1675
|
+
* 2. **The keep-list is data, not code.** `store-letitrip-official` and
|
|
1676
|
+
* `user-admin-letitrip` resolve `permanent`, so the same query skips them.
|
|
1677
|
+
* This replaces `cmdDelete`'s private `PROTECTED_USER_UIDS` set.
|
|
1678
|
+
* 3. **No composite index.** A single-field equality is served by Firestore's
|
|
1679
|
+
* automatic index, so this adds nothing to `firestore.indexes.json` — the
|
|
1680
|
+
* gap that left `priceIndex` queryless.
|
|
1681
|
+
* 4. **The cascade is mostly automatic.** Orders, bids, reviews, offers,
|
|
1682
|
+
* carts, payouts and notifications are all `test`, so a purged product's
|
|
1683
|
+
* dependents go in the same pass rather than needing a reference walk.
|
|
1684
|
+
*
|
|
1685
|
+
* 🛑 **Auth is a SECOND delete and `delete`/`purge` are the only places that
|
|
1686
|
+
* know it.** A Firestore user row and its Firebase Auth record are two objects;
|
|
1687
|
+
* removing the row and leaving the login is the worst of the three outcomes,
|
|
1688
|
+
* because the account then looks absent and can still authenticate.
|
|
1689
|
+
*
|
|
1690
|
+
* 🛑 **What it deliberately does NOT do**: delete organic rows that reference a
|
|
1691
|
+
* purged one. An organic notification pointing at a purged test order becomes a
|
|
1692
|
+
* dangling reference — and it is a real person's record, so it is REPORTED
|
|
1693
|
+
* rather than removed. Guessing in the destructive direction on someone's own
|
|
1694
|
+
* data is not a trade this command gets to make.
|
|
1695
|
+
*/
|
|
1696
|
+
async function cmdPurge() {
|
|
1697
|
+
const targets = collectionsToProcess;
|
|
1698
|
+
|
|
1699
|
+
/* Subcollections are not reachable by a top-level query, so they are walked
|
|
1700
|
+
* explicitly. `couponUsage` lives at `users/{uid}/couponUsage`. */
|
|
1701
|
+
const SUBCOLLECTIONS = [{ key: "couponUsage", parent: USER_COLLECTION, name: "couponUsage" }];
|
|
1702
|
+
|
|
1703
|
+
const plan = [];
|
|
1704
|
+
for (const colName of targets) {
|
|
1705
|
+
const path = COLLECTION_MAP[colName];
|
|
1706
|
+
if (!path) continue;
|
|
1707
|
+
const sub = SUBCOLLECTIONS.find((s) => s.key === colName);
|
|
1708
|
+
try {
|
|
1709
|
+
if (sub) {
|
|
1710
|
+
/*
|
|
1711
|
+
* 🛑 Unfiltered collection-group read, filtered in memory — NOT
|
|
1712
|
+
* `.where("seedPurpose", "==", "test")` on the group.
|
|
1713
|
+
*
|
|
1714
|
+
* A single-field equality is auto-indexed per COLLECTION, but a
|
|
1715
|
+
* COLLECTION-GROUP query needs an explicit composite/group index that
|
|
1716
|
+
* this project has never deployed, and `purge` must not require a
|
|
1717
|
+
* Firebase deploy just to run (it is a read-then-delete script, not an
|
|
1718
|
+
* infra change). Measured live: `3 FAILED_PRECONDITION: ... requires a
|
|
1719
|
+
* COLLECTION_GROUP_ASC index`. `couponUsage` is small (single digits
|
|
1720
|
+
* per store), so reading the whole group and filtering client-side
|
|
1721
|
+
* costs nothing here — this is not a pattern to reuse on a large
|
|
1722
|
+
* subcollection.
|
|
1723
|
+
*/
|
|
1724
|
+
const snap = await db.collectionGroup(sub.name).get();
|
|
1725
|
+
const docs = snap.docs.filter((d) => d.data()?.seedPurpose === "test");
|
|
1726
|
+
if (docs.length > 0) plan.push({ colName, docs, isSub: true });
|
|
1727
|
+
} else {
|
|
1728
|
+
const snap = await db.collection(path).where("seedPurpose", "==", "test").get();
|
|
1729
|
+
if (snap.size > 0) plan.push({ colName, docs: snap.docs, isSub: false });
|
|
1730
|
+
}
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
console.error(`✗ ${colName}: ${err.message}`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
const total = plan.reduce((n, p) => n + p.docs.length, 0);
|
|
1737
|
+
console.log(`\n${"collection".padEnd(26)} ${"purge".padStart(6)}`);
|
|
1738
|
+
console.log("─".repeat(36));
|
|
1739
|
+
for (const p of plan) {
|
|
1740
|
+
console.log(` ${p.colName.padEnd(24)} ${String(p.docs.length).padStart(6)}`);
|
|
1741
|
+
}
|
|
1742
|
+
console.log("─".repeat(36));
|
|
1743
|
+
console.log(` ${"TOTAL".padEnd(24)} ${String(total).padStart(6)}\n`);
|
|
1744
|
+
|
|
1745
|
+
if (total === 0) {
|
|
1746
|
+
console.log("Nothing to purge — no document carries seedPurpose \"test\".");
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
if (DRY_RUN) {
|
|
1751
|
+
console.log("--dry-run — nothing was deleted.");
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (
|
|
1756
|
+
!(await confirm(
|
|
1757
|
+
`Delete ${total} test document(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'? Permanent content and organic rows are untouched.`,
|
|
1758
|
+
))
|
|
1759
|
+
) {
|
|
1760
|
+
console.log("Aborted.");
|
|
1761
|
+
process.exit(0);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const stats = { deleted: 0, auth: 0, errors: 0 };
|
|
1765
|
+
for (const p of plan) {
|
|
1766
|
+
/*
|
|
1767
|
+
* 🛑 `users` first deletes the AUTH record, then the Firestore row — in that
|
|
1768
|
+
* order, because the reverse can leave an authenticating account whose
|
|
1769
|
+
* profile is gone, and this loop is the only thing that knows both exist.
|
|
1770
|
+
*/
|
|
1771
|
+
if (p.colName === "users") {
|
|
1772
|
+
for (const d of p.docs) {
|
|
1773
|
+
try {
|
|
1774
|
+
await auth.deleteUser(d.id);
|
|
1775
|
+
stats.auth++;
|
|
1776
|
+
} catch (e) {
|
|
1777
|
+
if (e?.code !== "auth/user-not-found") console.error(` auth ${d.id}: ${e.message}`);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
/*
|
|
1782
|
+
* 🛑 BulkWriter, never a manually-chunked `db.batch()`.
|
|
1783
|
+
*
|
|
1784
|
+
* A 450-document chunk stays under Firestore's 500-OPERATION limit but
|
|
1785
|
+
* says nothing about the 10 MiB per-request PAYLOAD limit — and a rich
|
|
1786
|
+
* document (a product's `searchTxt` array, `customFields`,
|
|
1787
|
+
* `customSections`, `images[]`) can blow that well before 450 count.
|
|
1788
|
+
* Measured live purging this exact `products` collection: `3
|
|
1789
|
+
* INVALID_ARGUMENT: Transaction too big` partway through, which aborted
|
|
1790
|
+
* the whole chunk's commit and silently left every doc in it undeleted —
|
|
1791
|
+
* `stats.deleted` kept counting the chunk as done regardless, so the
|
|
1792
|
+
* printed total was wrong by exactly the size of the failed chunk.
|
|
1793
|
+
*
|
|
1794
|
+
* `db.bulkWriter()` sizes and retries its own requests, so it is correct
|
|
1795
|
+
* regardless of document size — CLAUDE.md documents the identical failure
|
|
1796
|
+
* for a `categories` wipe (1,177 rows, ~100-entry `searchTxt` each) dying
|
|
1797
|
+
* at a 400-doc batch for the same reason.
|
|
1798
|
+
*/
|
|
1799
|
+
const writer = db.bulkWriter();
|
|
1800
|
+
let chunkErrors = 0;
|
|
1801
|
+
writer.onWriteError((err) => {
|
|
1802
|
+
chunkErrors += 1;
|
|
1803
|
+
console.error(`✗ ${p.colName}/${err.documentRef.id}: ${err.message}`);
|
|
1804
|
+
// Retry up to the default limit; beyond that, give up on this one doc
|
|
1805
|
+
// rather than retrying forever.
|
|
1806
|
+
return err.failedAttempts < 3;
|
|
1807
|
+
});
|
|
1808
|
+
for (const d of p.docs) {
|
|
1809
|
+
writer.delete(d.ref);
|
|
1810
|
+
if (VERBOSE) console.log(` - ${p.colName}/${d.id}`);
|
|
1811
|
+
}
|
|
1812
|
+
await writer.close();
|
|
1813
|
+
stats.deleted += p.docs.length - chunkErrors;
|
|
1814
|
+
stats.errors += chunkErrors;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
console.log(
|
|
1818
|
+
`\n${stats.errors === 0 ? "✓" : "✗"} Purge complete — deleted ${stats.deleted}, auth records ${stats.auth}, errors ${stats.errors}`,
|
|
1819
|
+
);
|
|
1820
|
+
if (stats.errors > 0) process.exit(1);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// ---------------------------------------------------------------------------
|
|
1824
|
+
try {
|
|
1825
|
+
if (ACTION === "status") await cmdStatus();
|
|
1826
|
+
else if (ACTION === "load") await cmdLoad();
|
|
1827
|
+
else if (ACTION === "delete") await cmdDelete();
|
|
1828
|
+
else if (ACTION === "purge") await cmdPurge();
|
|
1829
|
+
process.exit(0);
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
console.error("✗ Fatal:", err.message);
|
|
1832
|
+
if (VERBOSE) console.error(err.stack);
|
|
1833
|
+
process.exit(1);
|
|
1834
|
+
}
|