@mohasinac/appkit 4.42.18 → 4.44.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/_internal/client/features/lottery/LotteryDetailView.js +22 -1
- package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
- package/dist/_internal/server/features/categories/adapters.js +235 -0
- package/dist/_internal/server/features/checkout/actions.js +161 -22
- package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
- package/dist/_internal/server/features/orders/adapters.js +21 -0
- package/dist/_internal/server/features/products/list-public.d.ts +24 -0
- package/dist/_internal/server/features/products/list-public.js +10 -2
- package/dist/_internal/server/features/refunds/actions.js +1 -1
- package/dist/_internal/server/features/seo/sitemap.js +54 -15
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +6 -0
- package/dist/_internal/server/functions/scheduled.d.ts +4 -2
- package/dist/_internal/server/functions/scheduled.js +64 -6
- package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
- package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
- package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +59 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
- package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
- package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
- package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
- package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
- package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
- package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
- package/dist/_internal/server/jobs/handlers/index.js +3 -1
- package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
- package/dist/_internal/server/jobs/handlers/messages.js +5 -2
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
- package/dist/_internal/shared/actions/action-registry.js +34 -0
- package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
- package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
- package/dist/_internal/shared/checkout/order-math.js +194 -7
- package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
- package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
- package/dist/_internal/shared/fees/calculator.d.ts +105 -0
- package/dist/_internal/shared/fees/calculator.js +143 -0
- package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
- package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
- package/dist/_internal/shared/templating/placeholders.js +73 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/api-endpoints.d.ts +42 -0
- package/dist/constants/api-endpoints.js +14 -0
- package/dist/features/admin/actions/admin-actions.d.ts +1 -1
- package/dist/features/admin/actions/admin-actions.js +1 -1
- package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
- package/dist/features/admin/components/AdminProductEditorView.js +8 -1
- package/dist/features/admin/components/AdminProductsView.js +78 -7
- package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
- package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
- package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
- package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
- package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
- package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
- package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
- package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
- package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
- package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
- package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
- package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
- package/dist/features/auth/schemas/firestore.d.ts +0 -1
- package/dist/features/blog/api/route.js +16 -1
- package/dist/features/blog/schemas/firestore.d.ts +2 -2
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
- package/dist/features/categories/actions/category-actions.d.ts +2 -2
- package/dist/features/categories/actions/category-actions.js +22 -10
- package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
- package/dist/features/categories/api/[id]/description-template/route.js +252 -0
- package/dist/features/categories/api/route.js +156 -11
- package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
- package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
- package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
- package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
- package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
- package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
- package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
- package/dist/features/categories/constants/listing-categories.d.ts +17 -0
- package/dist/features/categories/constants/listing-categories.js +53 -0
- package/dist/features/categories/hooks/useCategoryTree.js +28 -2
- package/dist/features/categories/repository/categories.repository.d.ts +73 -12
- package/dist/features/categories/repository/categories.repository.js +203 -42
- package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
- package/dist/features/categories/repository/price-index.repository.js +85 -0
- package/dist/features/categories/schemas/category-content.d.ts +206 -0
- package/dist/features/categories/schemas/category-content.js +28 -0
- package/dist/features/categories/schemas/firestore.d.ts +74 -5
- package/dist/features/categories/schemas/firestore.js +74 -3
- package/dist/features/categories/schemas/index.d.ts +1047 -0
- package/dist/features/categories/schemas/index.js +96 -0
- package/dist/features/categories/server.d.ts +1 -0
- package/dist/features/categories/server.js +7 -0
- package/dist/features/categories/types/index.d.ts +9 -0
- package/dist/features/events/schemas/firestore.d.ts +2 -2
- package/dist/features/faq/schemas/firestore.d.ts +2 -1
- package/dist/features/faq/schemas/firestore.js +20 -8
- package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
- package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
- package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
- package/dist/features/homepage/lib/live-stats.js +18 -8
- package/dist/features/media/MediaImage.js +19 -2
- package/dist/features/orders/schemas/firestore.d.ts +70 -0
- package/dist/features/orders/types/index.d.ts +55 -0
- package/dist/features/products/actions/product-actions.d.ts +0 -9
- package/dist/features/products/actions/product-actions.js +25 -0
- package/dist/features/products/columns/index.d.ts +0 -1
- package/dist/features/products/columns/index.js +5 -1
- package/dist/features/products/components/ArtStickersListView.js +7 -1
- package/dist/features/products/components/PrizeDrawsSection.js +5 -0
- package/dist/features/products/components/ProductFilters.d.ts +0 -9
- package/dist/features/products/components/ProductForm.d.ts +27 -1
- package/dist/features/products/components/ProductForm.js +26 -12
- package/dist/features/products/components/ProductsIndexPageView.js +1 -1
- package/dist/features/products/constants/sieve.d.ts +0 -6
- package/dist/features/products/constants/sieve.js +35 -2
- package/dist/features/products/repository/products.repository.d.ts +115 -1
- package/dist/features/products/repository/products.repository.js +491 -39
- package/dist/features/products/schemas/firestore.d.ts +65 -5
- package/dist/features/products/schemas/firestore.js +9 -0
- package/dist/features/products/schemas/product-features.d.ts +140 -2
- package/dist/features/products/schemas/product-features.js +62 -1
- package/dist/features/promotions/repository/coupons.repository.js +6 -1
- package/dist/features/promotions/schemas/firestore.d.ts +44 -1
- package/dist/features/promotions/schemas/firestore.js +76 -2
- package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
- package/dist/features/reviews/repository/reviews.repository.js +35 -0
- package/dist/features/seller/actions/seller-actions.js +17 -2
- package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
- package/dist/features/seller/components/PrintCenterView.js +3 -2
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
- package/dist/features/seller/components/SellerOrdersView.js +6 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
- package/dist/features/seller/components/SellerProductShell.js +101 -9
- package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
- package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +108 -3
- package/dist/features/seller/types/index.d.ts +17 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
- package/dist/features/seller/utils/product-draft-mapping.js +17 -0
- package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
- package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
- package/dist/features/stores/schemas/firestore.d.ts +31 -2
- package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
- package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
- package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
- package/dist/features/tax-codes/schemas/firestore.js +82 -0
- package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
- package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
- package/dist/features/tester/actions/index.d.ts +0 -1
- package/dist/features/tester/actions/index.js +0 -1
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
- package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
- package/dist/features/tester/components/index.d.ts +0 -4
- package/dist/features/tester/components/index.js +0 -2
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
- package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
- package/dist/features/tester/schemas/firestore.d.ts +0 -6
- package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
- package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
- package/dist/features/tester/seed-data/authored/index.js +0 -2
- package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +10 -29
- package/dist/index.d.ts +21 -7
- package/dist/index.js +53 -7
- package/dist/next/routing/route-map.d.ts +2 -4
- package/dist/next/routing/route-map.js +1 -2
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +1 -0
- package/dist/schemas/registry.d.ts +506 -0
- package/dist/seed/_helpers/brand-rows.d.ts +35 -0
- package/dist/seed/_helpers/brand-rows.js +576 -0
- package/dist/seed/_helpers/category-content.d.ts +39 -0
- package/dist/seed/_helpers/category-content.js +634 -0
- package/dist/seed/_helpers/category-forest.d.ts +2 -0
- package/dist/seed/_helpers/category-forest.js +585 -243
- package/dist/seed/_helpers/category-models.d.ts +5 -0
- package/dist/seed/_helpers/category-models.js +1364 -0
- package/dist/seed/_helpers/category-tree.js +17 -1
- package/dist/seed/_helpers/character-features.d.ts +10 -0
- package/dist/seed/_helpers/character-features.js +118 -0
- package/dist/seed/_helpers/media.d.ts +1 -1
- package/dist/seed/_helpers/media.js +44 -8
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
- package/dist/seed/categories-seed-data.js +147 -169
- package/dist/seed/index.d.ts +1 -0
- package/dist/seed/index.js +1 -0
- package/dist/seed/manifest.d.ts +1 -1
- package/dist/seed/manifest.js +5 -0
- package/dist/seed/orders-seed-data.js +5 -3
- package/dist/seed/product-features-seed-data.d.ts +1 -8
- package/dist/seed/product-features-seed-data.js +358 -154
- package/dist/seed/products-art-seed-data.js +16 -4
- package/dist/seed/products-auctions-seed-data.js +6 -6
- package/dist/seed/products-classifieds-seed-data.js +17 -7
- package/dist/seed/products-digital-codes-seed-data.js +12 -12
- package/dist/seed/products-preorders-seed-data.js +8 -8
- package/dist/seed/products-prize-draws-seed-data.js +25 -8
- package/dist/seed/products-standard-seed-data.js +10 -60
- package/dist/seed/products-stickers-seed-data.js +6 -6
- package/dist/seed/site-settings-seed-data.js +24 -0
- package/dist/seed/store-extensions-seed-data.js +10 -4
- package/dist/seed/tax-codes-seed-data.d.ts +34 -0
- package/dist/seed/tax-codes-seed-data.js +100 -0
- package/dist/seed/test-data-ttl.d.ts +29 -0
- package/dist/seed/test-data-ttl.js +31 -0
- package/dist/server-entry.d.ts +1 -1
- package/dist/server-entry.js +6 -1
- package/dist/server.d.ts +4 -1
- package/dist/server.js +11 -2
- package/dist/styles.css +40 -0
- package/dist/ui/components/PaginatedSelect.d.ts +14 -0
- package/dist/ui/components/PaginatedSelect.js +0 -0
- package/dist/ui/components/PaginatedSelect.style.css +40 -0
- package/dist/utils/media-url.d.ts +26 -0
- package/dist/utils/media-url.js +144 -0
- package/dist/utils/physical-location.d.ts +23 -0
- package/dist/utils/physical-location.js +52 -0
- package/dist/utils/search-txt-builders.d.ts +20 -0
- package/dist/utils/search-txt-builders.js +36 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -1
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
- package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
- package/dist/features/tester/actions/leaderboard-actions.js +0 -5
- package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
- package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
- package/dist/features/tester/components/TesterHubView.d.ts +0 -5
- package/dist/features/tester/components/TesterHubView.js +0 -110
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
- package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
- package/dist/features/tester/seed-data/tester-ttl.js +0 -6
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core: evaluate the alert rules sellers and admins have configured — C3b.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 The defect this closes: there was no evaluator at all
|
|
5
|
+
*
|
|
6
|
+
* `analyticsAlerts` had a schema, a seller CRUD UI, two API routes and four
|
|
7
|
+
* seeded fixtures — and **zero references across all 59 job files and
|
|
8
|
+
* `functions/`**. `lastTriggeredAt` was declared and written nowhere. So every
|
|
9
|
+
* alert a seller configured, and every one the seed created, could never fire.
|
|
10
|
+
*
|
|
11
|
+
* CLAUDE.md overstates this feature for exactly that reason: the parts that
|
|
12
|
+
* exist look complete, and the one that was missing is the one nothing points
|
|
13
|
+
* at. It also hid the metric-vocabulary split that C3a closed — two disjoint
|
|
14
|
+
* lists of metric names cannot be noticed while nothing reads the field.
|
|
15
|
+
*
|
|
16
|
+
* ## The switch is EXHAUSTIVE over `AlertMetric`
|
|
17
|
+
*
|
|
18
|
+
* `satisfies never` on the default branch, so adding a vocabulary member
|
|
19
|
+
* without an implementation does not compile. That is the mechanism behind the
|
|
20
|
+
* vocabulary's rule — *a metric is offerable only if the evaluator computes
|
|
21
|
+
* it* — rather than a convention someone has to remember.
|
|
22
|
+
*
|
|
23
|
+
* ## Read cost, and why it is bounded
|
|
24
|
+
*
|
|
25
|
+
* Metrics are computed ONCE PER OWNER, not once per alert: a seller with four
|
|
26
|
+
* alerts on orders pays for one orders query. Each query is bounded by
|
|
27
|
+
* `QUERY_LIMIT` and every count is reported as saturated when it hits the cap,
|
|
28
|
+
* because a count that silently stopped at 500 is a threshold comparison
|
|
29
|
+
* against a number that is not the number.
|
|
30
|
+
*
|
|
31
|
+
* 🛑 It does NOT scan per-product for `low_stock`. `availableQuantity <= N` is
|
|
32
|
+
* an inequality, and pairing one with an arbitrary sort is what demands an
|
|
33
|
+
* index nobody declares (Root Cause #59) — so the window is bounded and the
|
|
34
|
+
* predicate runs in memory, the same asymmetry § Availability describes.
|
|
35
|
+
*/
|
|
36
|
+
import { serverLogger } from "../../../../monitoring";
|
|
37
|
+
import { sendNotification } from "../../../../features/admin/actions/notification-actions";
|
|
38
|
+
import { normalizeError } from "../../../../errors/normalize";
|
|
39
|
+
import { ORDER_FIELDS, PAYOUT_FIELDS, PRODUCT_FIELDS, } from "../../../../constants/field-names";
|
|
40
|
+
import { ProductStatusValues, PRODUCT_COLLECTION, } from "../../../../features/products/schemas/firestore";
|
|
41
|
+
import { ALERT_METRICS, LOW_STOCK_THRESHOLD, } from "../../../../features/store-extensions/constants/alert-metrics";
|
|
42
|
+
/** Bound on every count query. Reported when it saturates. */
|
|
43
|
+
const QUERY_LIMIT = 500;
|
|
44
|
+
/*
|
|
45
|
+
* Collection names and the two field names that have no entry in
|
|
46
|
+
* `constants/field-names.ts`.
|
|
47
|
+
*
|
|
48
|
+
* Declared here rather than inlined at the call site because
|
|
49
|
+
* `audit-registry-constants` is right about the general rule — a hand-typed
|
|
50
|
+
* field name in a query clause is how a rename passes a typecheck and returns
|
|
51
|
+
* zero rows. These two (`reports.status`, `analyticsAlerts.isActive`) belong
|
|
52
|
+
* in the shared registry the day a second reader appears; one local constant
|
|
53
|
+
* is the honest intermediate rather than adding a registry entry nothing else
|
|
54
|
+
* uses.
|
|
55
|
+
*/
|
|
56
|
+
const ORDERS_COLLECTION = "orders";
|
|
57
|
+
const REVIEWS_COLLECTION = "reviews";
|
|
58
|
+
const PAYOUTS_COLLECTION = "payouts";
|
|
59
|
+
const REPORTS_COLLECTION = "reports";
|
|
60
|
+
const USERS_COLLECTION = "users";
|
|
61
|
+
const REPORT_STATUS_FIELD = "status";
|
|
62
|
+
const ANALYTICS_ALERT_IS_ACTIVE_FIELD = "isActive";
|
|
63
|
+
/**
|
|
64
|
+
* An alert that is still true must not re-notify every run.
|
|
65
|
+
*
|
|
66
|
+
* Without this a seller with five products below five units gets a mail every
|
|
67
|
+
* hour until they restock — and the first thing anyone does with a channel
|
|
68
|
+
* that cries wolf is mute it, which costs the alerts that matter.
|
|
69
|
+
*/
|
|
70
|
+
const RENOTIFY_COOLDOWN_HOURS = 24;
|
|
71
|
+
/** Compare, with the operator the alert carries. */
|
|
72
|
+
export function alertFires(value, operator, threshold) {
|
|
73
|
+
switch (operator) {
|
|
74
|
+
case ">":
|
|
75
|
+
return value > threshold;
|
|
76
|
+
case "<":
|
|
77
|
+
return value < threshold;
|
|
78
|
+
case ">=":
|
|
79
|
+
return value >= threshold;
|
|
80
|
+
case "<=":
|
|
81
|
+
return value <= threshold;
|
|
82
|
+
case "==":
|
|
83
|
+
return value === threshold;
|
|
84
|
+
case "!=":
|
|
85
|
+
return value !== threshold;
|
|
86
|
+
default: {
|
|
87
|
+
/*
|
|
88
|
+
* Exhaustive. A seventh operator would not compile rather than silently
|
|
89
|
+
* never firing — which is the failure mode of a `default: return false`
|
|
90
|
+
* and is how an alert can look configured and be inert.
|
|
91
|
+
*/
|
|
92
|
+
const unreachable = operator;
|
|
93
|
+
return unreachable;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether enough time has passed to notify again.
|
|
99
|
+
*
|
|
100
|
+
* An alert that has NEVER fired always notifies — absence of
|
|
101
|
+
* `lastTriggeredAt` means "not yet", never "recently".
|
|
102
|
+
*/
|
|
103
|
+
export function mayRenotify(alert, now) {
|
|
104
|
+
if (!alert.lastTriggeredAt)
|
|
105
|
+
return true;
|
|
106
|
+
const last = new Date(alert.lastTriggeredAt).getTime();
|
|
107
|
+
if (Number.isNaN(last))
|
|
108
|
+
return true;
|
|
109
|
+
return now.getTime() - last >= RENOTIFY_COOLDOWN_HOURS * 60 * 60 * 1000;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 🛑 GOODS VALUE of a completed order, never `totalPrice`.
|
|
113
|
+
*
|
|
114
|
+
* C0 established that the grand total folds in GST, the platform fee,
|
|
115
|
+
* shipping and add-ons — and an alert reporting a seller's revenue as the
|
|
116
|
+
* buyer's grand total tells them they earned money we collected on somebody
|
|
117
|
+
* else's behalf. This is the same base a payout is computed from.
|
|
118
|
+
*
|
|
119
|
+
* An unpaid or refunded order contributes **zero**: delivered-then-refunded
|
|
120
|
+
* has a realised value of nothing, which is the same rule `priceIndexRollup`
|
|
121
|
+
* applies to the index.
|
|
122
|
+
*
|
|
123
|
+
* Extracted because `audit-code-quality` flagged the inline version at
|
|
124
|
+
* brace-depth 6 and it was right — "for each order, if paid, if not refunded,
|
|
125
|
+
* for each item, if live" is four questions in one block.
|
|
126
|
+
*/
|
|
127
|
+
function completedOrderGoodsValue(order) {
|
|
128
|
+
if (order.paymentStatus !== "paid")
|
|
129
|
+
return 0;
|
|
130
|
+
if (order.refunds && order.refunds.length > 0)
|
|
131
|
+
return 0;
|
|
132
|
+
return (order.items ?? []).reduce((sum, it) => {
|
|
133
|
+
const live = (it.quantity ?? 0) - (it.cancelledQuantity ?? 0);
|
|
134
|
+
return live > 0 ? sum + live * (it.unitPrice ?? 0) : sum;
|
|
135
|
+
}, 0);
|
|
136
|
+
}
|
|
137
|
+
function buildMetricCache(ctx, bucket, now, onSaturated) {
|
|
138
|
+
const cache = new Map();
|
|
139
|
+
const since = (windowHours) => new Date(now.getTime() - windowHours * 60 * 60 * 1000);
|
|
140
|
+
/** A bounded count, flagged when it hits the cap. */
|
|
141
|
+
const boundedCount = async (build) => {
|
|
142
|
+
const snap = await build().limit(QUERY_LIMIT).get();
|
|
143
|
+
return { value: snap.size, saturated: snap.size >= QUERY_LIMIT };
|
|
144
|
+
};
|
|
145
|
+
function buildQuery() {
|
|
146
|
+
return ctx.db.collection(PRODUCT_COLLECTION);
|
|
147
|
+
}
|
|
148
|
+
/** Seller metrics scope to the store; admin metrics span the platform. */
|
|
149
|
+
const storeScoped = (q) => bucket.scope === "seller" ? q.where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId) : q;
|
|
150
|
+
const compute = async (metric, windowHours) => {
|
|
151
|
+
switch (metric) {
|
|
152
|
+
case "low_stock": {
|
|
153
|
+
/*
|
|
154
|
+
* Bounded fetch + in-memory predicate, never `availableQuantity <= N`
|
|
155
|
+
* in the query. An inequality pushed alongside any other clause
|
|
156
|
+
* demands a composite index in an order nobody declares, and the
|
|
157
|
+
* failure mode is `FAILED_PRECONDITION` swallowed into a zero —
|
|
158
|
+
* i.e. an alert that silently never fires (Root Cause #59).
|
|
159
|
+
*/
|
|
160
|
+
const snap = await storeScoped(ctx.db.collection(PRODUCT_COLLECTION).where(PRODUCT_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED))
|
|
161
|
+
.limit(QUERY_LIMIT)
|
|
162
|
+
.get();
|
|
163
|
+
if (snap.size >= QUERY_LIMIT)
|
|
164
|
+
onSaturated(metric);
|
|
165
|
+
return snap.docs.filter((d) => {
|
|
166
|
+
const q = d.data().availableQuantity ?? 0;
|
|
167
|
+
return q > 0 && q <= LOW_STOCK_THRESHOLD;
|
|
168
|
+
}).length;
|
|
169
|
+
}
|
|
170
|
+
case "out_of_stock": {
|
|
171
|
+
const snap = await storeScoped(ctx.db.collection(PRODUCT_COLLECTION).where(PRODUCT_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED))
|
|
172
|
+
.limit(QUERY_LIMIT)
|
|
173
|
+
.get();
|
|
174
|
+
if (snap.size >= QUERY_LIMIT)
|
|
175
|
+
onSaturated(metric);
|
|
176
|
+
return snap.docs.filter((d) => (d.data().availableQuantity ?? 0) <= 0).length;
|
|
177
|
+
}
|
|
178
|
+
case "orders_count": {
|
|
179
|
+
const r = await boundedCount(() => storeScoped(ctx.db.collection(ORDERS_COLLECTION).where(ORDER_FIELDS.CREATED_AT, ">=", since(windowHours))));
|
|
180
|
+
if (r.saturated)
|
|
181
|
+
onSaturated(metric);
|
|
182
|
+
return r.value;
|
|
183
|
+
}
|
|
184
|
+
case "cancelled_orders": {
|
|
185
|
+
const r = await boundedCount(() => storeScoped(ctx.db
|
|
186
|
+
.collection("orders")
|
|
187
|
+
.where(ORDER_FIELDS.STATUS, "==", ORDER_FIELDS.STATUS_VALUES.CANCELLED)
|
|
188
|
+
.where(ORDER_FIELDS.CREATED_AT, ">=", since(windowHours))));
|
|
189
|
+
if (r.saturated)
|
|
190
|
+
onSaturated(metric);
|
|
191
|
+
return r.value;
|
|
192
|
+
}
|
|
193
|
+
case "revenue_total": {
|
|
194
|
+
const snap = await storeScoped(ctx.db
|
|
195
|
+
.collection("orders")
|
|
196
|
+
.where(ORDER_FIELDS.STATUS, "==", ORDER_FIELDS.STATUS_VALUES.DELIVERED)
|
|
197
|
+
.where(ORDER_FIELDS.CREATED_AT, ">=", since(windowHours)))
|
|
198
|
+
.limit(QUERY_LIMIT)
|
|
199
|
+
.get();
|
|
200
|
+
if (snap.size >= QUERY_LIMIT)
|
|
201
|
+
onSaturated(metric);
|
|
202
|
+
/*
|
|
203
|
+
* 🛑 GOODS VALUE, not `totalPrice`. C0 established that the grand
|
|
204
|
+
* total folds in GST, the platform fee, shipping and add-ons — and an
|
|
205
|
+
* alert that reports a seller's revenue as the buyer's grand total
|
|
206
|
+
* tells them they earned money we collected on somebody else's
|
|
207
|
+
* behalf. Same base the payout is computed from.
|
|
208
|
+
*/
|
|
209
|
+
const total = snap.docs.reduce((sum, d) => sum + completedOrderGoodsValue(d.data()), 0);
|
|
210
|
+
return Math.round(total * 100) / 100;
|
|
211
|
+
}
|
|
212
|
+
case "pending_payout_total": {
|
|
213
|
+
const snap = await (bucket.scope === "seller"
|
|
214
|
+
? ctx.db.collection(PAYOUTS_COLLECTION).where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId)
|
|
215
|
+
: ctx.db.collection(PAYOUTS_COLLECTION))
|
|
216
|
+
.where(PAYOUT_FIELDS.STATUS, "in", ["PENDING", "PROCESSING"])
|
|
217
|
+
.limit(QUERY_LIMIT)
|
|
218
|
+
.get();
|
|
219
|
+
if (snap.size >= QUERY_LIMIT)
|
|
220
|
+
onSaturated(metric);
|
|
221
|
+
let total = 0;
|
|
222
|
+
for (const d of snap.docs)
|
|
223
|
+
total += d.data().amount ?? 0;
|
|
224
|
+
return Math.round(total * 100) / 100;
|
|
225
|
+
}
|
|
226
|
+
case "avg_rating": {
|
|
227
|
+
const snap = await ctx.db
|
|
228
|
+
.collection("reviews")
|
|
229
|
+
.where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId)
|
|
230
|
+
.limit(QUERY_LIMIT)
|
|
231
|
+
.get();
|
|
232
|
+
if (snap.size >= QUERY_LIMIT)
|
|
233
|
+
onSaturated(metric);
|
|
234
|
+
const ratings = snap.docs
|
|
235
|
+
.map((d) => d.data().rating)
|
|
236
|
+
.filter((r) => typeof r === "number");
|
|
237
|
+
/*
|
|
238
|
+
* 🛑 No reviews returns 0, and 0 would FIRE a `< 4` alert — telling a
|
|
239
|
+
* brand-new store its rating has collapsed when it has no rating at
|
|
240
|
+
* all. `NaN` instead, which every comparison answers false to, so the
|
|
241
|
+
* alert stays quiet until there is something to judge.
|
|
242
|
+
*/
|
|
243
|
+
if (ratings.length === 0)
|
|
244
|
+
return Number.NaN;
|
|
245
|
+
return Math.round((ratings.reduce((a, b) => a + b, 0) / ratings.length) * 100) / 100;
|
|
246
|
+
}
|
|
247
|
+
case "reviews_count": {
|
|
248
|
+
const r = await boundedCount(() => ctx.db
|
|
249
|
+
.collection("reviews")
|
|
250
|
+
.where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId)
|
|
251
|
+
.where(ORDER_FIELDS.CREATED_AT, ">=", since(windowHours)));
|
|
252
|
+
if (r.saturated)
|
|
253
|
+
onSaturated(metric);
|
|
254
|
+
return r.value;
|
|
255
|
+
}
|
|
256
|
+
case "open_reports": {
|
|
257
|
+
const r = await boundedCount(() => ctx.db.collection(REPORTS_COLLECTION).where(REPORT_STATUS_FIELD, "==", "pending"));
|
|
258
|
+
if (r.saturated)
|
|
259
|
+
onSaturated(metric);
|
|
260
|
+
return r.value;
|
|
261
|
+
}
|
|
262
|
+
case "pending_moderation": {
|
|
263
|
+
const r = await boundedCount(() => ctx.db.collection(PRODUCT_COLLECTION).where(PRODUCT_FIELDS.STATUS, "==", "in_review"));
|
|
264
|
+
if (r.saturated)
|
|
265
|
+
onSaturated(metric);
|
|
266
|
+
return r.value;
|
|
267
|
+
}
|
|
268
|
+
default: {
|
|
269
|
+
/*
|
|
270
|
+
* 🛑 Exhaustive over `AlertMetric`. A vocabulary member with no
|
|
271
|
+
* implementation does NOT compile — which is what makes the
|
|
272
|
+
* vocabulary's rule ("offerable only if computable") a mechanism
|
|
273
|
+
* rather than a note. The original feature had the opposite: seven
|
|
274
|
+
* offerable metrics, none computable.
|
|
275
|
+
*/
|
|
276
|
+
const unreachable = metric;
|
|
277
|
+
return unreachable;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
return {
|
|
282
|
+
get(metric, windowHours) {
|
|
283
|
+
/*
|
|
284
|
+
* Keyed on metric AND window, because two alerts on `orders_count` with
|
|
285
|
+
* 24h and 48h windows are genuinely two different numbers. Keying on the
|
|
286
|
+
* metric alone would serve one alert the other's answer.
|
|
287
|
+
*/
|
|
288
|
+
const key = `${metric}:${windowHours}`;
|
|
289
|
+
let p = cache.get(key);
|
|
290
|
+
if (!p) {
|
|
291
|
+
p = compute(metric, windowHours);
|
|
292
|
+
cache.set(key, p);
|
|
293
|
+
}
|
|
294
|
+
return p;
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The owner's contact details, resolved once per owner.
|
|
300
|
+
*
|
|
301
|
+
* 🛑 This is how the seller's `notifyChannels` is HONOURED rather than
|
|
302
|
+
* ignored. `sendNotification` decides email and WhatsApp eligibility itself
|
|
303
|
+
* (type gate → admin allow-list → user prefs → daily budget) and takes no
|
|
304
|
+
* channel list — but it cannot email an address it was not given. So omitting
|
|
305
|
+
* `userEmail` when the seller did not tick email is a real narrowing through
|
|
306
|
+
* the existing gate, with no second channel-routing mechanism invented beside
|
|
307
|
+
* it.
|
|
308
|
+
*
|
|
309
|
+
* The in-app row is always written, which matches the UI: "in-app" is the
|
|
310
|
+
* default channel and there is no way to turn the bell off.
|
|
311
|
+
*/
|
|
312
|
+
async function resolveOwnerContact(ctx, bucket) {
|
|
313
|
+
/*
|
|
314
|
+
* A seller alert's `ownerId` is the OWNER'S UID (the seed writes
|
|
315
|
+
* `user-tyson-blader`), not the store slug — unlike the metric queries
|
|
316
|
+
* above, which scope on `storeId`. Those are two different identifiers and
|
|
317
|
+
* conflating them is Root Cause #98's shape, so they are read from two
|
|
318
|
+
* different places on purpose.
|
|
319
|
+
*/
|
|
320
|
+
const snap = await ctx.db.collection(USERS_COLLECTION).doc(bucket.ownerId).get();
|
|
321
|
+
if (!snap.exists)
|
|
322
|
+
return {};
|
|
323
|
+
const u = snap.data();
|
|
324
|
+
return {
|
|
325
|
+
...(u.email ? { email: u.email } : {}),
|
|
326
|
+
...(u.phoneNumber ? { phone: u.phoneNumber } : {}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
export async function runAnalyticsAlertEvaluate(ctx) {
|
|
330
|
+
const now = new Date();
|
|
331
|
+
const snap = await ctx.db
|
|
332
|
+
.collection("analyticsAlerts")
|
|
333
|
+
.where(ANALYTICS_ALERT_IS_ACTIVE_FIELD, "==", true)
|
|
334
|
+
.limit(QUERY_LIMIT)
|
|
335
|
+
.get();
|
|
336
|
+
if (snap.size >= QUERY_LIMIT) {
|
|
337
|
+
ctx.logger.warn("analyticsAlertEvaluate: alert scan saturated — some alerts were not evaluated", {
|
|
338
|
+
limit: QUERY_LIMIT,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/* Group by owner so each metric is computed once, not once per alert. */
|
|
342
|
+
const buckets = new Map();
|
|
343
|
+
for (const d of snap.docs) {
|
|
344
|
+
const alert = { id: d.id, ...d.data() };
|
|
345
|
+
if (!alert.ownerId || !ALERT_METRICS[alert.metric]) {
|
|
346
|
+
/*
|
|
347
|
+
* A stored metric outside the vocabulary. Logged rather than skipped
|
|
348
|
+
* silently: these are the pre-C3a rows, and the only other way to see
|
|
349
|
+
* one is that the seller's list renders a raw token with no description.
|
|
350
|
+
*/
|
|
351
|
+
serverLogger.warn("analyticsAlertEvaluate: alert carries an unknown metric", {
|
|
352
|
+
alertId: d.id,
|
|
353
|
+
metric: alert.metric,
|
|
354
|
+
});
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const key = `${alert.scope}:${alert.ownerId}`;
|
|
358
|
+
const bucket = buckets.get(key) ?? { scope: alert.scope, ownerId: alert.ownerId, alerts: [] };
|
|
359
|
+
bucket.alerts.push(alert);
|
|
360
|
+
buckets.set(key, bucket);
|
|
361
|
+
}
|
|
362
|
+
let evaluated = 0;
|
|
363
|
+
let fired = 0;
|
|
364
|
+
let suppressed = 0;
|
|
365
|
+
let errored = 0;
|
|
366
|
+
for (const bucket of buckets.values()) {
|
|
367
|
+
const saturatedMetrics = new Set();
|
|
368
|
+
const cache = buildMetricCache(ctx, bucket, now, (m) => saturatedMetrics.add(m));
|
|
369
|
+
/** One read per owner, and only when something actually fires. */
|
|
370
|
+
let contact = null;
|
|
371
|
+
const notifyOwner = async (b, alert, value) => {
|
|
372
|
+
contact ?? (contact = await resolveOwnerContact(ctx, b));
|
|
373
|
+
const def = ALERT_METRICS[alert.metric];
|
|
374
|
+
await sendNotification({
|
|
375
|
+
/*
|
|
376
|
+
* `system`, deliberately, rather than a new `analytics_alert` type.
|
|
377
|
+
*
|
|
378
|
+
* A new type touches five maps and — more to the point — would need an
|
|
379
|
+
* `EMAIL_ELIGIBLE_TYPES` answer. Against a 100/day Resend ceiling, an
|
|
380
|
+
* email-eligible type driven by a user-configurable threshold is the
|
|
381
|
+
* fastest way to exhaust the allowance. `system` is already eligible
|
|
382
|
+
* and already carries operational messages, which is what this is.
|
|
383
|
+
*/
|
|
384
|
+
type: "system",
|
|
385
|
+
userId: b.ownerId,
|
|
386
|
+
title: alert.label || def.label,
|
|
387
|
+
priority: "normal",
|
|
388
|
+
message: `${def.label} is ${value} (${alert.operator} ${alert.threshold} over the last ${alert.windowHours}h). ${def.describes}`,
|
|
389
|
+
...(alert.notifyChannels?.includes("email") && contact.email
|
|
390
|
+
? { userEmail: contact.email }
|
|
391
|
+
: {}),
|
|
392
|
+
...(alert.notifyChannels?.includes("whatsapp") && contact.phone
|
|
393
|
+
? { userPhone: contact.phone }
|
|
394
|
+
: {}),
|
|
395
|
+
audience: b.scope === "admin" ? "admin" : "seller",
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
for (const alert of bucket.alerts) {
|
|
399
|
+
try {
|
|
400
|
+
const value = await cache.get(alert.metric, alert.windowHours ?? 24);
|
|
401
|
+
evaluated += 1;
|
|
402
|
+
/*
|
|
403
|
+
* 🛑 A SATURATED count must not fire. The real number is at least the
|
|
404
|
+
* cap, so a `<` comparison against it is meaningless and a `>` one is
|
|
405
|
+
* true for the wrong reason. Reporting "we could not measure this" is
|
|
406
|
+
* the honest outcome — the same rule as `truncated` rendering "50+"
|
|
407
|
+
* rather than a false total.
|
|
408
|
+
*/
|
|
409
|
+
if (saturatedMetrics.has(alert.metric)) {
|
|
410
|
+
suppressed += 1;
|
|
411
|
+
ctx.logger.warn("analyticsAlertEvaluate: skipped on a saturated metric", {
|
|
412
|
+
alertId: alert.id,
|
|
413
|
+
metric: alert.metric,
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (Number.isNaN(value)) {
|
|
418
|
+
suppressed += 1;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (!alertFires(value, alert.operator, alert.threshold))
|
|
422
|
+
continue;
|
|
423
|
+
if (!mayRenotify(alert, now)) {
|
|
424
|
+
suppressed += 1;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
/*
|
|
428
|
+
* The write happens FIRST, before the notification.
|
|
429
|
+
*
|
|
430
|
+
* If the notification throws, the cooldown has still been recorded —
|
|
431
|
+
* so a persistently failing channel cannot turn into one attempt per
|
|
432
|
+
* run forever. The inverse order would retry a broken WhatsApp send
|
|
433
|
+
* every hour against a 100/day email allowance.
|
|
434
|
+
*/
|
|
435
|
+
await ctx.db
|
|
436
|
+
.collection("analyticsAlerts")
|
|
437
|
+
.doc(alert.id)
|
|
438
|
+
.update({ lastTriggeredAt: now, lastObservedValue: value, updatedAt: now });
|
|
439
|
+
fired += 1;
|
|
440
|
+
await notifyOwner(bucket, alert, value);
|
|
441
|
+
ctx.logger.info("analyticsAlertEvaluate: alert fired", {
|
|
442
|
+
alertId: alert.id,
|
|
443
|
+
ownerId: alert.ownerId,
|
|
444
|
+
metric: alert.metric,
|
|
445
|
+
value,
|
|
446
|
+
operator: alert.operator,
|
|
447
|
+
threshold: alert.threshold,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
const e = normalizeError(err);
|
|
452
|
+
errored += 1;
|
|
453
|
+
ctx.logger.error("analyticsAlertEvaluate: alert evaluation failed", {
|
|
454
|
+
alertId: alert.id,
|
|
455
|
+
metric: alert.metric,
|
|
456
|
+
message: e.message,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
ctx.logger.info("analyticsAlertEvaluate: complete", {
|
|
462
|
+
owners: buckets.size,
|
|
463
|
+
evaluated,
|
|
464
|
+
fired,
|
|
465
|
+
suppressed,
|
|
466
|
+
errored,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { orderRepository, payoutRepository, storeRepository, userRepository, siteSettingsRepository, } from "../../../../repositories";
|
|
2
2
|
import { getDefaultCurrency } from "../../../../core/index";
|
|
3
3
|
import { computePayoutDeduction } from "../../../shared/fees/calculator";
|
|
4
|
+
import { ordersTaxableValue } from "../../../shared/checkout/order-math";
|
|
4
5
|
import { BATCH_LIMIT } from "../handlers/messages";
|
|
5
6
|
const AUTO_PAYOUT_WINDOW_DAYS = 7;
|
|
6
7
|
function getBusinessDayCutoff(daysAgo) {
|
|
@@ -48,7 +49,19 @@ export async function runAutoPayoutEligibility(ctx) {
|
|
|
48
49
|
continue;
|
|
49
50
|
}
|
|
50
51
|
const sellerId = store.ownerId;
|
|
51
|
-
|
|
52
|
+
/*
|
|
53
|
+
* The TAXABLE VALUE — see `orderTaxableValue`. Two narrowings, both
|
|
54
|
+
* needed, and the variable keeps the name `grossAmount` only because
|
|
55
|
+
* `PayoutDocument.grossAmount` does:
|
|
56
|
+
*
|
|
57
|
+
* 1. not `totalPrice`, the grand total, which folds in GST, the platform
|
|
58
|
+
* fee the buyer already paid, shipping, COD handling, add-ons and the
|
|
59
|
+
* EMI surcharge — so it paid out our GST, took the fee twice, and
|
|
60
|
+
* computed every percentage on an inflated base;
|
|
61
|
+
* 2. not the gross goods value either, because prices are tax-INCLUSIVE,
|
|
62
|
+
* so the goods value still has our GST inside it.
|
|
63
|
+
*/
|
|
64
|
+
const grossAmount = ordersTaxableValue(orders.map((o) => o.data));
|
|
52
65
|
const { platformFee, gatewayFee, gstOnFee: gstAmount, netAmount } = computePayoutDeduction(grossAmount, commissions);
|
|
53
66
|
const payoutInput = {
|
|
54
67
|
storeId,
|
|
@@ -203,22 +203,38 @@ async function reconcileStores(ctx) {
|
|
|
203
203
|
let errors = 0;
|
|
204
204
|
for (const storeId of storeIds) {
|
|
205
205
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
206
|
+
/*
|
|
207
|
+
* 🛑 `storeId`, NOT a `sellerId` resolved from `ownerId`. Every store on
|
|
208
|
+
* the platform has been getting 0 products and 0 delivered orders
|
|
209
|
+
* written to its stats, nightly, in silence.
|
|
210
|
+
*
|
|
211
|
+
* Two compounding defects were here. The field `sellerId` **exists on
|
|
212
|
+
* neither `ProductDocument` nor `OrderDocument`** — measured, zero
|
|
213
|
+
* occurrences in either schema; both carry `storeId`. And the value was
|
|
214
|
+
* `store.ownerId`, which is the Auth UID, while `storeId` IS the store
|
|
215
|
+
* slug (§ Store Identity Architecture). So the query named a field no
|
|
216
|
+
* document has, with a value from the wrong namespace.
|
|
217
|
+
*
|
|
218
|
+
* Firestore answers an equality on a missing field with ZERO rows and
|
|
219
|
+
* raises nothing, so `setStats(storeId, 0, 0, …)` wrote confident zeros
|
|
220
|
+
* over whatever the live triggers had computed. That is Root Cause
|
|
221
|
+
* #100's family: a reader whose field no document carries returns
|
|
222
|
+
* nothing and reads as "this store has sold nothing".
|
|
223
|
+
*
|
|
224
|
+
* The store document read is gone with it — the loop already holds the
|
|
225
|
+
* id it needed, so resolving `ownerId` was a read per store spent on
|
|
226
|
+
* getting the answer wrong.
|
|
227
|
+
*/
|
|
212
228
|
const [productsSnap, ordersSnap, reviewStats] = await Promise.all([
|
|
213
229
|
ctx.db
|
|
214
230
|
.collection(PRODUCT_COLLECTION)
|
|
215
|
-
.where(
|
|
231
|
+
.where(PRODUCT_FIELDS.STORE_ID, "==", storeId)
|
|
216
232
|
.where(ORDER_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED)
|
|
217
233
|
.limit(QUERY_LIMIT)
|
|
218
234
|
.get(),
|
|
219
235
|
ctx.db
|
|
220
236
|
.collection(ORDERS_COLLECTION)
|
|
221
|
-
.where(ORDER_FIELDS.
|
|
237
|
+
.where(ORDER_FIELDS.STORE_ID, "==", storeId)
|
|
222
238
|
.where(ORDER_FIELDS.STATUS, "==", ORDER_FIELDS.STATUS_VALUES.DELIVERED)
|
|
223
239
|
.limit(QUERY_LIMIT)
|
|
224
240
|
.get(),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { notifyRevalidate } from "./revalidateNotify";
|
|
1
2
|
import { normalizeError } from "../../../../errors/normalize";
|
|
2
3
|
/**
|
|
3
4
|
* Core: maintain DFS `position` + `subtreeSize` on every category write.
|
|
@@ -37,6 +38,12 @@ import { normalizeError } from "../../../../errors/normalize";
|
|
|
37
38
|
* itself, and each of those writes spawns a no-op invocation. A full reseed of the
|
|
38
39
|
* 58-category forest therefore costs on the order of thousands of invocations, all
|
|
39
40
|
* of them terminating. That is a cost worth knowing before a reseed, not a runaway.
|
|
41
|
+
*
|
|
42
|
+
* 🛑 AND THAT COST IS QUADRATIC, which is why the CREATE branch now has a
|
|
43
|
+
* precomputed-coordinates escape hatch (see the block at the top of `isCreate`).
|
|
44
|
+
* ~n²/4 writes: 58 nodes ≈ 840, 330 ≈ 27,000, 1,500 ≈ 560,000 — the last two
|
|
45
|
+
* being 1.4x and 28x the 20k/day Firestore write budget. A seed that already
|
|
46
|
+
* carries correct DFS coordinates skips the branch entirely.
|
|
40
47
|
*/
|
|
41
48
|
import { FieldValue } from "firebase-admin/firestore";
|
|
42
49
|
import { BATCH_LIMIT } from "../handlers/messages";
|
|
@@ -87,11 +94,63 @@ async function getMaxPosition(ctx, excludeId) {
|
|
|
87
94
|
}
|
|
88
95
|
export async function handleCategoryWrite(input, ctx) {
|
|
89
96
|
const { categoryId, before, after } = input;
|
|
97
|
+
// Drop the ISR entries for this category/brand/bundle page and the listings
|
|
98
|
+
// that link it. Folded into the existing trigger rather than given its own —
|
|
99
|
+
// see the note in onProductWrite. No Firestore write here (Root Cause #92).
|
|
100
|
+
await notifyRevalidate({ collection: "categories", id: categoryId }, ctx);
|
|
90
101
|
const isCreate = !before && !!after;
|
|
91
102
|
const isDelete = !!before && !after;
|
|
92
103
|
const isUpdate = !!before && !!after;
|
|
93
104
|
try {
|
|
94
105
|
if (isCreate) {
|
|
106
|
+
/*
|
|
107
|
+
* 🛑 BULK-WRITE ESCAPE HATCH — skip everything when the writer already
|
|
108
|
+
* computed valid DFS coordinates.
|
|
109
|
+
*
|
|
110
|
+
* The branch below is O(n) reads + O(n) writes PER CATEGORY CREATED:
|
|
111
|
+
* `shiftPositions` runs an unbounded `where("position", ">=", t)` and
|
|
112
|
+
* rewrites every row it returns, because a new node is inserted as its
|
|
113
|
+
* parent's FIRST child and everything after it moves. Averaged over a
|
|
114
|
+
* build that is ~n/2 rows shifted per insert, so seeding N categories
|
|
115
|
+
* costs Σ(i/2) ≈ n²/4 writes:
|
|
116
|
+
*
|
|
117
|
+
* 58 nodes -> ~840 writes (tolerable, and what we pay today)
|
|
118
|
+
* 330 nodes -> ~27,000 writes (above the 20k/day Firestore budget)
|
|
119
|
+
* 1,500 nodes -> ~560,000 writes (28x the daily budget, in one run)
|
|
120
|
+
*
|
|
121
|
+
* `buildCategoryTree` already computes `position` and `subtreeSize` as
|
|
122
|
+
* a correct global DFS pre-order — the exact numbering this handler
|
|
123
|
+
* would otherwise recompute one insert at a time. Worse than wasteful:
|
|
124
|
+
* the CREATE branch OVERWRITES that work with `subtreeSize: 1` and a
|
|
125
|
+
* locally-derived position, leaving the collection wrong until the
|
|
126
|
+
* nightly reconcile repairs it.
|
|
127
|
+
*
|
|
128
|
+
* So a document that arrives already numbered is left alone.
|
|
129
|
+
*
|
|
130
|
+
* The test is `position >= 1`, which is unambiguous because BOTH
|
|
131
|
+
* writers of a precomputed position are 1-based: `buildCategoryTree`
|
|
132
|
+
* and `positionsReconcile`. A category created through the admin/store
|
|
133
|
+
* routes carries `position: 0` (the `createWithHierarchy` default) and
|
|
134
|
+
* therefore still gets the full treatment.
|
|
135
|
+
*
|
|
136
|
+
* 🛑 This is NOT the Root Cause #92 shape. It is not a value-equality
|
|
137
|
+
* guard on a field this handler writes — it is a precondition on the
|
|
138
|
+
* INCOMING document, and the handler takes no action at all when it
|
|
139
|
+
* holds. There is nothing to compare and nothing to re-trigger.
|
|
140
|
+
*/
|
|
141
|
+
const precomputedPosition = after.position;
|
|
142
|
+
const precomputedSubtree = after.subtreeSize;
|
|
143
|
+
if (typeof precomputedPosition === "number" &&
|
|
144
|
+
precomputedPosition >= 1 &&
|
|
145
|
+
typeof precomputedSubtree === "number" &&
|
|
146
|
+
precomputedSubtree >= 1) {
|
|
147
|
+
ctx.logger.info("Category created with precomputed DFS coords — skipping position assignment", {
|
|
148
|
+
categoryId,
|
|
149
|
+
position: precomputedPosition,
|
|
150
|
+
subtreeSize: precomputedSubtree,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
95
154
|
const parentIds = after.parentIds ?? [];
|
|
96
155
|
const parentId = parentIds.length > 0 ? parentIds[parentIds.length - 1] : null;
|
|
97
156
|
let insertPosition;
|
|
@@ -7,6 +7,7 @@ import { categoriesRepository, storeRepository, } from "../../../../repositories
|
|
|
7
7
|
import { ProductStatusValues } from "../../../../features/products/schemas/firestore";
|
|
8
8
|
import { PRODUCT_FIELDS } from "../../../../constants/field-names";
|
|
9
9
|
import { isListingRowAvailable } from "../../../shared/listing-types/_registry";
|
|
10
|
+
import { notifyRevalidate, productCacheFieldsChanged } from "./revalidateNotify";
|
|
10
11
|
const UNAVAILABLE_PRODUCT_STATUSES = new Set([
|
|
11
12
|
ProductStatusValues.ARCHIVED,
|
|
12
13
|
]);
|
|
@@ -158,6 +159,36 @@ async function dispatchProductWriteEvent(p) {
|
|
|
158
159
|
}
|
|
159
160
|
export async function handleProductWrite(input, ctx) {
|
|
160
161
|
const { productId, before, after } = input;
|
|
162
|
+
/*
|
|
163
|
+
* Drop the ISR entries for every page that renders this product.
|
|
164
|
+
*
|
|
165
|
+
* Deliberately folded into THIS handler rather than given a trigger of its
|
|
166
|
+
* own. `products/{productId}` already carries two `documentWritten` triggers;
|
|
167
|
+
* a third would add a whole function's worth of invocations for one `fetch`,
|
|
168
|
+
* and Root Cause #92's collateral is the standing warning about what happens
|
|
169
|
+
* when several functions share one hot path.
|
|
170
|
+
*
|
|
171
|
+
* Gated on a real content change so a stock decrement invalidates and an
|
|
172
|
+
* `updatedAt` bump does not. Awaited — not fire-and-forget — because a Cloud
|
|
173
|
+
* Function may be frozen the moment its promise settles, and a detached fetch
|
|
174
|
+
* would be cancelled mid-flight often enough to make invalidation unreliable
|
|
175
|
+
* without ever reporting a failure.
|
|
176
|
+
*/
|
|
177
|
+
if (productCacheFieldsChanged(before, after)) {
|
|
178
|
+
const doc = after ?? before;
|
|
179
|
+
await notifyRevalidate({
|
|
180
|
+
collection: "products",
|
|
181
|
+
id: productId,
|
|
182
|
+
hints: {
|
|
183
|
+
listingType: doc?.listingType,
|
|
184
|
+
categorySlugs: Array.isArray(doc?.categorySlugs)
|
|
185
|
+
? doc.categorySlugs
|
|
186
|
+
: undefined,
|
|
187
|
+
brandSlug: doc?.brandSlug,
|
|
188
|
+
storeId: doc?.storeId,
|
|
189
|
+
},
|
|
190
|
+
}, ctx);
|
|
191
|
+
}
|
|
161
192
|
const beforeStatus = before?.status ?? null;
|
|
162
193
|
const afterStatus = after?.status ?? null;
|
|
163
194
|
// `category` is @deprecated in favor of `categorySlugs[]` — the repository's
|