@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.
Files changed (263) hide show
  1. package/dist/_internal/client/features/lottery/LotteryDetailView.js +22 -1
  2. package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
  3. package/dist/_internal/server/features/categories/adapters.js +235 -0
  4. package/dist/_internal/server/features/checkout/actions.js +161 -22
  5. package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
  6. package/dist/_internal/server/features/orders/adapters.js +21 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +24 -0
  8. package/dist/_internal/server/features/products/list-public.js +10 -2
  9. package/dist/_internal/server/features/refunds/actions.js +1 -1
  10. package/dist/_internal/server/features/seo/sitemap.js +54 -15
  11. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/stores/adapters.js +6 -0
  13. package/dist/_internal/server/functions/scheduled.d.ts +4 -2
  14. package/dist/_internal/server/functions/scheduled.js +64 -6
  15. package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
  16. package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
  17. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
  18. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
  19. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
  20. package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
  21. package/dist/_internal/server/jobs/core/onCategoryWrite.js +59 -0
  22. package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
  23. package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
  24. package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
  26. package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
  27. package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
  28. package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
  29. package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
  30. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
  31. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
  32. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
  33. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
  34. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
  35. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
  36. package/dist/_internal/server/jobs/handlers/index.js +3 -1
  37. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
  38. package/dist/_internal/server/jobs/handlers/messages.js +5 -2
  39. package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
  40. package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
  41. package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
  42. package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
  43. package/dist/_internal/shared/actions/action-registry.js +34 -0
  44. package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
  45. package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
  46. package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
  47. package/dist/_internal/shared/checkout/order-math.js +194 -7
  48. package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
  49. package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
  50. package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
  51. package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
  52. package/dist/_internal/shared/fees/calculator.d.ts +105 -0
  53. package/dist/_internal/shared/fees/calculator.js +143 -0
  54. package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
  55. package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
  56. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
  57. package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
  58. package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
  59. package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
  60. package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
  61. package/dist/_internal/shared/templating/placeholders.js +73 -0
  62. package/dist/client.d.ts +2 -0
  63. package/dist/client.js +1 -0
  64. package/dist/constants/api-endpoints.d.ts +42 -0
  65. package/dist/constants/api-endpoints.js +14 -0
  66. package/dist/features/admin/actions/admin-actions.d.ts +1 -1
  67. package/dist/features/admin/actions/admin-actions.js +1 -1
  68. package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
  69. package/dist/features/admin/components/AdminProductEditorView.js +8 -1
  70. package/dist/features/admin/components/AdminProductsView.js +78 -7
  71. package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
  72. package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
  73. package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
  74. package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
  75. package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
  76. package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
  77. package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
  78. package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
  79. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  80. package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
  81. package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
  82. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
  83. package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
  84. package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
  85. package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
  86. package/dist/features/auth/schemas/firestore.d.ts +0 -1
  87. package/dist/features/blog/api/route.js +16 -1
  88. package/dist/features/blog/schemas/firestore.d.ts +2 -2
  89. package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
  90. package/dist/features/categories/actions/category-actions.d.ts +2 -2
  91. package/dist/features/categories/actions/category-actions.js +22 -10
  92. package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
  93. package/dist/features/categories/api/[id]/description-template/route.js +252 -0
  94. package/dist/features/categories/api/route.js +156 -11
  95. package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
  96. package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
  97. package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
  98. package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
  99. package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
  100. package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
  101. package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
  102. package/dist/features/categories/constants/listing-categories.d.ts +17 -0
  103. package/dist/features/categories/constants/listing-categories.js +53 -0
  104. package/dist/features/categories/hooks/useCategoryTree.js +28 -2
  105. package/dist/features/categories/repository/categories.repository.d.ts +73 -12
  106. package/dist/features/categories/repository/categories.repository.js +203 -42
  107. package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
  108. package/dist/features/categories/repository/price-index.repository.js +85 -0
  109. package/dist/features/categories/schemas/category-content.d.ts +206 -0
  110. package/dist/features/categories/schemas/category-content.js +28 -0
  111. package/dist/features/categories/schemas/firestore.d.ts +74 -5
  112. package/dist/features/categories/schemas/firestore.js +74 -3
  113. package/dist/features/categories/schemas/index.d.ts +1047 -0
  114. package/dist/features/categories/schemas/index.js +96 -0
  115. package/dist/features/categories/server.d.ts +1 -0
  116. package/dist/features/categories/server.js +7 -0
  117. package/dist/features/categories/types/index.d.ts +9 -0
  118. package/dist/features/events/schemas/firestore.d.ts +2 -2
  119. package/dist/features/faq/schemas/firestore.d.ts +2 -1
  120. package/dist/features/faq/schemas/firestore.js +20 -8
  121. package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
  122. package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
  123. package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
  124. package/dist/features/homepage/lib/live-stats.js +18 -8
  125. package/dist/features/media/MediaImage.js +19 -2
  126. package/dist/features/orders/schemas/firestore.d.ts +70 -0
  127. package/dist/features/orders/types/index.d.ts +55 -0
  128. package/dist/features/products/actions/product-actions.d.ts +0 -9
  129. package/dist/features/products/actions/product-actions.js +25 -0
  130. package/dist/features/products/columns/index.d.ts +0 -1
  131. package/dist/features/products/columns/index.js +5 -1
  132. package/dist/features/products/components/ArtStickersListView.js +7 -1
  133. package/dist/features/products/components/PrizeDrawsSection.js +5 -0
  134. package/dist/features/products/components/ProductFilters.d.ts +0 -9
  135. package/dist/features/products/components/ProductForm.d.ts +27 -1
  136. package/dist/features/products/components/ProductForm.js +26 -12
  137. package/dist/features/products/components/ProductsIndexPageView.js +1 -1
  138. package/dist/features/products/constants/sieve.d.ts +0 -6
  139. package/dist/features/products/constants/sieve.js +35 -2
  140. package/dist/features/products/repository/products.repository.d.ts +115 -1
  141. package/dist/features/products/repository/products.repository.js +491 -39
  142. package/dist/features/products/schemas/firestore.d.ts +65 -5
  143. package/dist/features/products/schemas/firestore.js +9 -0
  144. package/dist/features/products/schemas/product-features.d.ts +140 -2
  145. package/dist/features/products/schemas/product-features.js +62 -1
  146. package/dist/features/promotions/repository/coupons.repository.js +6 -1
  147. package/dist/features/promotions/schemas/firestore.d.ts +44 -1
  148. package/dist/features/promotions/schemas/firestore.js +76 -2
  149. package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
  150. package/dist/features/reviews/repository/reviews.repository.js +35 -0
  151. package/dist/features/seller/actions/seller-actions.js +17 -2
  152. package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
  153. package/dist/features/seller/components/PrintCenterView.js +3 -2
  154. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
  155. package/dist/features/seller/components/SellerOrdersView.js +6 -1
  156. package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
  157. package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
  158. package/dist/features/seller/components/SellerProductShell.js +101 -9
  159. package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
  160. package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
  161. package/dist/features/seller/components/SellerProductsView.js +108 -3
  162. package/dist/features/seller/types/index.d.ts +17 -0
  163. package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
  164. package/dist/features/seller/utils/product-draft-mapping.js +17 -0
  165. package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
  166. package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
  167. package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
  168. package/dist/features/stores/schemas/firestore.d.ts +31 -2
  169. package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
  170. package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
  171. package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
  172. package/dist/features/tax-codes/schemas/firestore.js +82 -0
  173. package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
  174. package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
  175. package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
  176. package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
  177. package/dist/features/tester/actions/index.d.ts +0 -1
  178. package/dist/features/tester/actions/index.js +0 -1
  179. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
  180. package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
  181. package/dist/features/tester/components/index.d.ts +0 -4
  182. package/dist/features/tester/components/index.js +0 -2
  183. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
  184. package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
  185. package/dist/features/tester/schemas/firestore.d.ts +0 -6
  186. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
  187. package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
  188. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
  189. package/dist/features/tester/seed-data/authored/index.js +0 -2
  190. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
  191. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +10 -29
  192. package/dist/index.d.ts +21 -7
  193. package/dist/index.js +53 -7
  194. package/dist/next/routing/route-map.d.ts +2 -4
  195. package/dist/next/routing/route-map.js +1 -2
  196. package/dist/repositories/index.d.ts +1 -0
  197. package/dist/repositories/index.js +1 -0
  198. package/dist/schemas/registry.d.ts +506 -0
  199. package/dist/seed/_helpers/brand-rows.d.ts +35 -0
  200. package/dist/seed/_helpers/brand-rows.js +576 -0
  201. package/dist/seed/_helpers/category-content.d.ts +39 -0
  202. package/dist/seed/_helpers/category-content.js +634 -0
  203. package/dist/seed/_helpers/category-forest.d.ts +2 -0
  204. package/dist/seed/_helpers/category-forest.js +585 -243
  205. package/dist/seed/_helpers/category-models.d.ts +5 -0
  206. package/dist/seed/_helpers/category-models.js +1364 -0
  207. package/dist/seed/_helpers/category-tree.js +17 -1
  208. package/dist/seed/_helpers/character-features.d.ts +10 -0
  209. package/dist/seed/_helpers/character-features.js +118 -0
  210. package/dist/seed/_helpers/media.d.ts +1 -1
  211. package/dist/seed/_helpers/media.js +44 -8
  212. package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
  213. package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
  214. package/dist/seed/categories-seed-data.js +147 -169
  215. package/dist/seed/index.d.ts +1 -0
  216. package/dist/seed/index.js +1 -0
  217. package/dist/seed/manifest.d.ts +1 -1
  218. package/dist/seed/manifest.js +5 -0
  219. package/dist/seed/orders-seed-data.js +5 -3
  220. package/dist/seed/product-features-seed-data.d.ts +1 -8
  221. package/dist/seed/product-features-seed-data.js +358 -154
  222. package/dist/seed/products-art-seed-data.js +16 -4
  223. package/dist/seed/products-auctions-seed-data.js +6 -6
  224. package/dist/seed/products-classifieds-seed-data.js +17 -7
  225. package/dist/seed/products-digital-codes-seed-data.js +12 -12
  226. package/dist/seed/products-preorders-seed-data.js +8 -8
  227. package/dist/seed/products-prize-draws-seed-data.js +25 -8
  228. package/dist/seed/products-standard-seed-data.js +10 -60
  229. package/dist/seed/products-stickers-seed-data.js +6 -6
  230. package/dist/seed/site-settings-seed-data.js +24 -0
  231. package/dist/seed/store-extensions-seed-data.js +10 -4
  232. package/dist/seed/tax-codes-seed-data.d.ts +34 -0
  233. package/dist/seed/tax-codes-seed-data.js +100 -0
  234. package/dist/seed/test-data-ttl.d.ts +29 -0
  235. package/dist/seed/test-data-ttl.js +31 -0
  236. package/dist/server-entry.d.ts +1 -1
  237. package/dist/server-entry.js +6 -1
  238. package/dist/server.d.ts +4 -1
  239. package/dist/server.js +11 -2
  240. package/dist/styles.css +40 -0
  241. package/dist/ui/components/PaginatedSelect.d.ts +14 -0
  242. package/dist/ui/components/PaginatedSelect.js +0 -0
  243. package/dist/ui/components/PaginatedSelect.style.css +40 -0
  244. package/dist/utils/media-url.d.ts +26 -0
  245. package/dist/utils/media-url.js +144 -0
  246. package/dist/utils/physical-location.d.ts +23 -0
  247. package/dist/utils/physical-location.js +52 -0
  248. package/dist/utils/search-txt-builders.d.ts +20 -0
  249. package/dist/utils/search-txt-builders.js +36 -0
  250. package/package.json +1 -1
  251. package/scripts/seed-cli.mjs +4 -1
  252. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
  253. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
  254. package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
  255. package/dist/features/tester/actions/leaderboard-actions.js +0 -5
  256. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
  257. package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
  258. package/dist/features/tester/components/TesterHubView.d.ts +0 -5
  259. package/dist/features/tester/components/TesterHubView.js +0 -110
  260. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
  261. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
  262. package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
  263. package/dist/features/tester/seed-data/tester-ttl.js +0 -6
@@ -1,15 +1,45 @@
1
1
  import { normalizeError } from "../../../errors/normalize";
2
2
  import { increment, arrayUnion, arrayRemove, } from "../../../contracts/field-ops";
3
3
  import { DatabaseError } from "../../../errors";
4
- import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
4
+ import { BaseRepository, prepareForFirestore, parseSieveDateValue, applySieveToFirestore, } from "../../../providers/db-firebase";
5
+ import { buildCategorySearchTxt } from "../../../utils/search-txt-builders";
6
+ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
5
7
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
6
8
  import { CATEGORY_FIELDS, CATEGORIES_COLLECTION, MIN_ITEMS_FOR_FEATURED, buildCategoryTree, calculateCategoryFields, canBeFeatured, createCategoryId, isValidCategoryMove, } from "../schemas";
7
9
  export class CategoriesRepository extends BaseRepository {
8
10
  constructor() {
9
11
  super(CATEGORIES_COLLECTION);
10
12
  }
11
- async list(model) {
12
- return this.sieveQuery(model, CategoriesRepository.SIEVE_FIELDS);
13
+ /** Derived on every write path via `applyWriteHooks`. */
14
+ buildSearchTxtFor(data) {
15
+ return buildCategorySearchTxt(data);
16
+ }
17
+ async list(model, opts) {
18
+ /*
19
+ * `q` is a searchTxt pushdown, NOT a Sieve filter.
20
+ *
21
+ * `plan.head` becomes one `array-contains` on the longest term and
22
+ * `plan.rest` is AND-refined in memory — the same two-part shape the other
23
+ * ten migrated collections use. Going through `planSearchTxt` rather than
24
+ * hand-rolling it is what buys the `empty` guard: a query that normalises
25
+ * to no usable token (`"!!!"`, `"—"`) must return NOTHING, because
26
+ * emitting no clause at all returns the whole collection and calls it a
27
+ * search result. That is the live bug `products` still has (A2).
28
+ */
29
+ const plan = planSearchTxt(opts?.search);
30
+ if (plan.empty)
31
+ return emptySearchResult();
32
+ if (!plan.head) {
33
+ return this.sieveQuery(model, CategoriesRepository.SIEVE_FIELDS);
34
+ }
35
+ const baseQuery = this.getCollection().where(CATEGORY_FIELDS.SEARCH_TXT, "array-contains", plan.head);
36
+ const result = await applySieveToFirestore({
37
+ baseQuery,
38
+ model,
39
+ fields: CategoriesRepository.SIEVE_FIELDS,
40
+ mapDoc: (snap) => this.mapDoc(snap),
41
+ });
42
+ return refineSearchTxt(result, plan.rest);
13
43
  }
14
44
  async createWithHierarchy(input) {
15
45
  try {
@@ -112,14 +142,26 @@ export class CategoriesRepository extends BaseRepository {
112
142
  throw new DatabaseError(`Failed to retrieve leaf categories: ${error instanceof Error ? error.message : "Unknown error"}`);
113
143
  }
114
144
  }
115
- async getCategoriesByTier(tier) {
145
+ /**
146
+ * @param limit 0 = unbounded (the historical behaviour). When > 0 the bound is
147
+ * pushed into Firestore instead of slicing after the read.
148
+ *
149
+ * 🛑 The limit is applied with HEADROOM, not exactly, because `!isBrand` is a
150
+ * post-fetch filter: brand rows live in this same collection (they are
151
+ * `CategoryDocument`s discriminated by `isBrand`), so a bare `.limit(n)` could
152
+ * return n brand rows, filter all of them out, and yield an empty tier. The
153
+ * caller still slices to its own limit.
154
+ */
155
+ async getCategoriesByTier(tier, limit = 0) {
116
156
  try {
117
- const snapshot = await this.db
157
+ let query = this.db
118
158
  .collection(this.collection)
119
159
  .where(CATEGORY_FIELDS.TIER, "==", tier)
120
160
  .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
121
- .orderBy(CATEGORY_FIELDS.ORDER, "asc")
122
- .get();
161
+ .orderBy(CATEGORY_FIELDS.ORDER, "asc");
162
+ if (limit > 0)
163
+ query = query.limit(limit * 4);
164
+ const snapshot = await query.get();
123
165
  return snapshot.docs
124
166
  .map((doc) => this.mapDoc(doc))
125
167
  .filter((category) => !category.isBrand);
@@ -129,21 +171,139 @@ export class CategoriesRepository extends BaseRepository {
129
171
  throw new DatabaseError(`Failed to retrieve categories by tier: ${error instanceof Error ? error.message : "Unknown error"}`);
130
172
  }
131
173
  }
132
- async getCategoriesByRootId(rootId) {
174
+ /**
175
+ * Every category sharing a root, tier-then-order.
176
+ *
177
+ * 🛑 `limit` is for DISPLAY callers only, and it is a real truncation.
178
+ * `buildTree(rootId)` must pass nothing: a tree built from a truncated node
179
+ * list silently drops whole branches, which is the exact failure
180
+ * `getDescendants`'s docstring was rewritten to avoid. A display grid of
181
+ * sibling categories, by contrast, renders ~20 tiles and has no reason to
182
+ * read 1,500 documents to do it.
183
+ *
184
+ * Paginated rather than one unbounded `.get()`: the previous form read the
185
+ * whole root subtree into memory on every category page render, which at the
186
+ * ~330-node forest this plan seeds is ~9% of a 50K/day Firestore budget for
187
+ * ONE page view.
188
+ */
189
+ /**
190
+ * Several categories by id, in ONE round trip, in the order asked for.
191
+ *
192
+ * The ancestor-walk primitive. A five-tier tree means a leaf has up to four
193
+ * ancestors, and resolving an inherited description template or an inherited
194
+ * `productDefaults` means reading them nearest-first until the answer is
195
+ * found — which as four sequential `findById` calls is four round trips, on
196
+ * a route the seller form hits on every category change. Rule #6 budgets
197
+ * about three for a whole request.
198
+ *
199
+ * 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled. Callers
200
+ * walk the result looking for the first document that declares a thing, so
201
+ * "nearest first" has to survive the round trip — Firestore's `getAll`
202
+ * already answers in argument order, and this keeps that guarantee explicit
203
+ * rather than incidental. A caller needing to know WHICH id was missing
204
+ * should compare lengths; none does, because an ancestor that no longer
205
+ * exists is a data problem for the reconcile job, not a reason to fail a
206
+ * form.
207
+ *
208
+ * Bounded at 30 to match `array-contains-any`'s cap, so this cannot become
209
+ * the unbounded read that `getCategoriesByRootId` had to be rewritten to
210
+ * avoid being.
211
+ */
212
+ async findByIds(ids) {
213
+ const unique = Array.from(new Set(ids.filter(Boolean))).slice(0, 30);
214
+ if (unique.length === 0)
215
+ return [];
133
216
  try {
134
- const snapshot = await this.db
135
- .collection(this.collection)
136
- .where(CATEGORY_FIELDS.ROOT_ID, "==", rootId)
137
- .orderBy(CATEGORY_FIELDS.TIER, "asc")
138
- .orderBy(CATEGORY_FIELDS.ORDER, "asc")
139
- .get();
140
- return snapshot.docs.map((doc) => this.mapDoc(doc));
217
+ const refs = unique.map((id) => this.db.collection(this.collection).doc(id));
218
+ const snaps = await this.db.getAll(...refs);
219
+ return snaps
220
+ .filter((s) => s.exists)
221
+ .map((s) => this.mapDoc(s));
222
+ }
223
+ catch (err) {
224
+ const e = normalizeError(err);
225
+ /*
226
+ * 🛑 The raw `.message` is NOT interpolated into the thrown error — only
227
+ * the normalised `kind` and a count.
228
+ *
229
+ * Several older methods in this file do interpolate it, and
230
+ * `audit-raw-error-text` flagged this one for trying. The reasoning is
231
+ * Root Cause #86's: a thrown value's message is written for a developer,
232
+ * and this one reaches a user through the route's 500 branch — which is
233
+ * how a Node `MODULE_NOT_FOUND` with a full require stack ended up
234
+ * rendered inside the "Place your bid" modal.
235
+ *
236
+ * The ids are counted rather than listed. A category id is not
237
+ * sensitive, but 30 of them in one line is noise against Hobby's ~4 KB/s
238
+ * log budget.
239
+ *
240
+ * (`normalizeError` has to be the FIRST statement, not merely nearby:
241
+ * `audit-catch-normalize` looks within a few lines of the catch, and the
242
+ * first draft put this comment above the call and tripped it.)
243
+ */
244
+ throw new DatabaseError(`Failed to read categories by id (${unique.length} requested): ${e.kind}`);
245
+ }
246
+ }
247
+ async getCategoriesByRootId(rootId, opts) {
248
+ try {
249
+ const cap = opts?.limit;
250
+ const pageSize = cap ? Math.min(cap, 300) : 300;
251
+ const out = [];
252
+ let cursor = null;
253
+ for (;;) {
254
+ let q = this.db
255
+ .collection(this.collection)
256
+ .where(CATEGORY_FIELDS.ROOT_ID, "==", rootId)
257
+ .orderBy(CATEGORY_FIELDS.TIER, "asc")
258
+ .orderBy(CATEGORY_FIELDS.ORDER, "asc")
259
+ .limit(pageSize);
260
+ if (cursor)
261
+ q = q.startAfter(cursor);
262
+ const page = await q.get();
263
+ if (page.empty)
264
+ break;
265
+ for (const doc of page.docs)
266
+ out.push(this.mapDoc(doc));
267
+ if (cap && out.length >= cap)
268
+ return out.slice(0, cap);
269
+ if (page.size < pageSize)
270
+ break;
271
+ cursor = page.docs[page.docs.length - 1];
272
+ }
273
+ return out;
141
274
  }
142
275
  catch (error) {
143
276
  void normalizeError(error);
144
277
  throw new DatabaseError(`Failed to retrieve categories by rootId: ${error instanceof Error ? error.message : "Unknown error"}`);
145
278
  }
146
279
  }
280
+ /**
281
+ * Categories whose `productDefaults.taxCodeId` points at `taxCodeId`.
282
+ *
283
+ * 🛑 Exists so DELETE on a tax code can REFUSE while it is referenced.
284
+ * `taxCodeId` is a plain string with no referential integrity, and a
285
+ * dangling reference does not error — `findResolvable` answers `null`,
286
+ * whose correct handling is "leave the product's gstRate alone", so every
287
+ * subsequent listing under that category silently derives NO TAX. It does
288
+ * not fail; it under-charges, until someone reads an invoice.
289
+ *
290
+ * One equality on a dot-path — served by the automatic index, no composite
291
+ * to declare. Bounded at 200: the answer is used to NAME referrers in a 409
292
+ * message, and the first five are what the admin reads.
293
+ */
294
+ async findByTaxCodeId(taxCodeId) {
295
+ try {
296
+ const snap = await this.getCollection()
297
+ .where(CATEGORY_FIELDS.PRODUCT_DEFAULTS_TAX_CODE_ID, "==", taxCodeId)
298
+ .limit(200)
299
+ .get();
300
+ return snap.docs.map((d) => this.mapDoc(d));
301
+ }
302
+ catch (error) {
303
+ void normalizeError(error);
304
+ throw new DatabaseError(`Failed to find categories by tax code: ${error instanceof Error ? error.message : "Unknown error"}`);
305
+ }
306
+ }
147
307
  async getChildren(parentId) {
148
308
  try {
149
309
  const snapshot = await this.db
@@ -161,17 +321,16 @@ export class CategoriesRepository extends BaseRepository {
161
321
  throw new DatabaseError(`Failed to retrieve children categories: ${error instanceof Error ? error.message : "Unknown error"}`);
162
322
  }
163
323
  }
164
- /**
165
- * IDs of every descendant category at any depth (not just direct
166
- * children) — `parentIds` stores the full ancestor chain, so a single
167
- * array-contains query on that field already returns the whole subtree
168
- * with no recursion needed. Used to expand a parent category's product
169
- * listing/count to include products filed under any of its children.
324
+ /*
325
+ * 🛑 `getDescendantIds` was DELETED (2026-10-10). It was
326
+ * `getDescendants(id).then(d => d.map(x => x.id))`, and its only caller —
327
+ * CategoryDetailPageView — already held the documents from its own
328
+ * `getDescendants` call in the same render. So it re-ran the identical
329
+ * paginated subtree query and discarded everything but the ids: ~330 wasted
330
+ * reads per page view at the forest size this plan seeds, and two answers
331
+ * that could disagree whenever one read failed and the other did not.
332
+ * Call `getDescendants` and `.map(d => d.id)` at the one site that needs it.
170
333
  */
171
- async getDescendantIds(categoryId) {
172
- const docs = await this.getDescendants(categoryId);
173
- return docs.map((d) => d.id);
174
- }
175
334
  /**
176
335
  * Every descendant DOCUMENT at any depth.
177
336
  *
@@ -230,12 +389,18 @@ export class CategoriesRepository extends BaseRepository {
230
389
  }
231
390
  async getBrandCategories(limit = 0) {
232
391
  try {
233
- const snapshot = await this.db
392
+ // The limit is pushed into Firestore rather than sliced after the read.
393
+ // It used to fetch EVERY brand and `.slice()` — so `limit` reduced what
394
+ // the caller saw but not what was billed. Exact here (no post-filter to
395
+ // under-fill), unlike `getCategoriesByTier` above.
396
+ let query = this.db
234
397
  .collection(this.collection)
235
398
  .where("isBrand", "==", true)
236
399
  .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
237
- .orderBy(CATEGORY_FIELDS.ORDER, "asc")
238
- .get();
400
+ .orderBy(CATEGORY_FIELDS.ORDER, "asc");
401
+ if (limit > 0)
402
+ query = query.limit(limit);
403
+ const snapshot = await query.get();
239
404
  const brands = snapshot.docs.map((doc) => this.mapDoc(doc));
240
405
  return limit > 0 ? brands.slice(0, limit) : brands;
241
406
  }
@@ -678,24 +843,19 @@ export class CategoriesRepository extends BaseRepository {
678
843
  .get();
679
844
  return snap.docs.map((d) => this.mapDoc(d));
680
845
  }
846
+ /*
847
+ * 🛑 `incrementViewCount` DELETED 2026-10-10 — zero callers since
848
+ * 2026-08-31, when it was removed from the category-detail render path (a
849
+ * Firestore write per render, and a second counter racing `pageViews`).
850
+ *
851
+ * Measured: `viewCount` is absent from all 58 category documents. Deleting
852
+ * the writer nobody calls is what stops the dead field reading as live. See
853
+ * the products repository for the same removal.
854
+ */
681
855
  /**
682
856
  * SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
683
857
  * human-entered category name.
684
858
  */
685
- async incrementViewCount(categoryId) {
686
- try {
687
- await this.db
688
- .collection(this.collection)
689
- .doc(categoryId)
690
- .update({ [CATEGORY_FIELDS.VIEW_COUNT]: increment(1) });
691
- // Fire-and-forget analytics: a lost view increment changes nothing that
692
- // renders, so it must never break the category read path it rides along
693
- // with — the count is approximate by design.
694
- }
695
- catch (_err) {
696
- void normalizeError(_err);
697
- }
698
- }
699
859
  generateSublistingId(name) {
700
860
  const base = name
701
861
  .toLowerCase()
@@ -712,6 +872,7 @@ CategoriesRepository.SIEVE_FIELDS = {
712
872
  isActive: { canFilter: true, canSort: false },
713
873
  isFeatured: { canFilter: true, canSort: false },
714
874
  isBrand: { canFilter: true, canSort: false },
875
+ searchTxt: { canFilter: true, canSort: false },
715
876
  categoryType: { canFilter: true, canSort: false },
716
877
  isSearchable: { canFilter: true, canSort: false },
717
878
  parentId: { canFilter: true, canSort: false, path: "parentIds" },
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Price Index Repository — what things actually SOLD for here, per leaf.
3
+ *
4
+ * ## 🛑 Why this is its own collection and not a field on `categories`
5
+ *
6
+ * The obvious design is `categories/{leafId}.productDefaults.priceGuidance.sold`,
7
+ * beside the seed-owned `market` band it is shown next to. That design is a
8
+ * runaway-trigger hazard and it is not a theoretical one.
9
+ *
10
+ * `onCategoryWrite` is a `documentWritten` trigger on `categories`, so a job
11
+ * writing a price band there re-triggers it once per document. That is exactly
12
+ * the shape of Root Cause #92, where `onShipmentHeaderWrite` wrote back to the
13
+ * collection it watched and produced **1,017,548 invocations in 24 hours**
14
+ * against a 2M/month allowance, blocked `firebase deploy` with a billing
15
+ * denial, and degraded production.
16
+ *
17
+ * It is also worse than that here, because `notifyRevalidate` fires on every
18
+ * category write — so each nightly price write would drop that category page's
19
+ * ISR entry, on a project that was paused for exceeding ISR writes by 15.5×.
20
+ *
21
+ * A sibling collection removes the self-write entirely. It costs one extra
22
+ * read on the surface that shows the band, and buys immunity.
23
+ *
24
+ * ## Shape
25
+ *
26
+ * priceIndex/{leafId} the current band
27
+ * priceIndex/{leafId}/daily/{YYYYMMDD} one point per day, for a series
28
+ *
29
+ * 🛑 NO `soldPrices: number[]` on the document. `CategoryDocument.metrics`
30
+ * already carries two unbounded arrays and a third is the 1 MiB trap. The
31
+ * samples stay in job memory and only the summary is written.
32
+ */
33
+ import { BaseRepository } from "../../../providers/db-firebase";
34
+ /** Which acquisition path a sale came through. Counts, not amounts. */
35
+ export interface PriceIndexByPath {
36
+ standard?: number;
37
+ auction?: number;
38
+ offer?: number;
39
+ }
40
+ export interface PriceIndexDocument {
41
+ /** The LEAF category id. A sale rolls up to exactly one leaf. */
42
+ id: string;
43
+ currency: "INR";
44
+ /**
45
+ * Scoped by listing type WITHIN the leaf.
46
+ *
47
+ * 🛑 Load-bearing. A named-model leaf holds every listing type for that
48
+ * model — the bey, its stickers, its spare parts, a used one as a classified
49
+ * — because `listingType` and `categorySlugs` are independent axes. A ₹399
50
+ * sticker sheet and a ₹1,299 bey under one leaf would blend into a
51
+ * meaningless band, so the band is per type and the UI shows the one the
52
+ * seller is listing.
53
+ */
54
+ byListingType: Record<string, PriceIndexBand>;
55
+ /** How many days of completed sales the bands cover. */
56
+ windowDays: number;
57
+ computedAt: Date;
58
+ }
59
+ export interface PriceIndexBand {
60
+ min: number;
61
+ median: number;
62
+ max: number;
63
+ /**
64
+ * 🛑 The confidence signal, and the UI must render a small one as a sample
65
+ * size with NO headline number — the same discipline as `truncated`
66
+ * rendering "50+" rather than a false total. A median of two sales is a coin
67
+ * flip presented as data.
68
+ */
69
+ sampleSize: number;
70
+ byPath?: PriceIndexByPath;
71
+ lastSold?: {
72
+ amount: number;
73
+ at: Date;
74
+ via?: "standard" | "auction" | "offer";
75
+ };
76
+ }
77
+ /** One day's point, for a series. */
78
+ export interface PriceIndexDailyPoint {
79
+ id: string;
80
+ /** `YYYYMMDD`, so the document id sorts chronologically on its own. */
81
+ date: string;
82
+ byListingType: Record<string, Pick<PriceIndexBand, "median" | "sampleSize">>;
83
+ computedAt: Date;
84
+ }
85
+ export declare const PRICE_INDEX_COLLECTION = "priceIndex";
86
+ /**
87
+ * Below this, a band renders as a sample size and no headline figure.
88
+ *
89
+ * Exported because the renderer and the job must agree: a threshold the job
90
+ * applied and the UI did not would hide bands that exist, and the reverse
91
+ * would publish a median of one sale as a market rate.
92
+ */
93
+ export declare const PRICE_INDEX_MIN_CONFIDENT_SAMPLES = 3;
94
+ export declare class PriceIndexRepository extends BaseRepository<PriceIndexDocument> {
95
+ constructor();
96
+ getForLeaf(leafId: string): Promise<PriceIndexDocument | null>;
97
+ /**
98
+ * Write the current band for one leaf.
99
+ *
100
+ * `createWithId`/`update` rather than a blind `set`, matching every other
101
+ * rollup repository here — and `update` merges, so a leaf whose band for one
102
+ * listing type disappeared this run would keep the stale entry. Hence
103
+ * `byListingType` is replaced wholesale by passing the full map.
104
+ */
105
+ setForLeaf(leafId: string, data: Pick<PriceIndexDocument, "byListingType" | "windowDays">): Promise<void>;
106
+ /**
107
+ * Append today's point.
108
+ *
109
+ * Keyed on the date, so a re-run on the same day OVERWRITES rather than
110
+ * double-counting — the same idempotency `pageViewRollup`'s `throughDate`
111
+ * buys, achieved here by the document id instead of a cursor.
112
+ */
113
+ setDailyPoint(leafId: string, date: string, byListingType: PriceIndexDailyPoint["byListingType"]): Promise<void>;
114
+ }
115
+ export declare const priceIndexRepository: PriceIndexRepository;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Price Index Repository — what things actually SOLD for here, per leaf.
3
+ *
4
+ * ## 🛑 Why this is its own collection and not a field on `categories`
5
+ *
6
+ * The obvious design is `categories/{leafId}.productDefaults.priceGuidance.sold`,
7
+ * beside the seed-owned `market` band it is shown next to. That design is a
8
+ * runaway-trigger hazard and it is not a theoretical one.
9
+ *
10
+ * `onCategoryWrite` is a `documentWritten` trigger on `categories`, so a job
11
+ * writing a price band there re-triggers it once per document. That is exactly
12
+ * the shape of Root Cause #92, where `onShipmentHeaderWrite` wrote back to the
13
+ * collection it watched and produced **1,017,548 invocations in 24 hours**
14
+ * against a 2M/month allowance, blocked `firebase deploy` with a billing
15
+ * denial, and degraded production.
16
+ *
17
+ * It is also worse than that here, because `notifyRevalidate` fires on every
18
+ * category write — so each nightly price write would drop that category page's
19
+ * ISR entry, on a project that was paused for exceeding ISR writes by 15.5×.
20
+ *
21
+ * A sibling collection removes the self-write entirely. It costs one extra
22
+ * read on the surface that shows the band, and buys immunity.
23
+ *
24
+ * ## Shape
25
+ *
26
+ * priceIndex/{leafId} the current band
27
+ * priceIndex/{leafId}/daily/{YYYYMMDD} one point per day, for a series
28
+ *
29
+ * 🛑 NO `soldPrices: number[]` on the document. `CategoryDocument.metrics`
30
+ * already carries two unbounded arrays and a third is the 1 MiB trap. The
31
+ * samples stay in job memory and only the summary is written.
32
+ */
33
+ import { BaseRepository } from "../../../providers/db-firebase";
34
+ export const PRICE_INDEX_COLLECTION = "priceIndex";
35
+ const DAILY_SUBCOLLECTION = "daily";
36
+ /**
37
+ * Below this, a band renders as a sample size and no headline figure.
38
+ *
39
+ * Exported because the renderer and the job must agree: a threshold the job
40
+ * applied and the UI did not would hide bands that exist, and the reverse
41
+ * would publish a median of one sale as a market rate.
42
+ */
43
+ export const PRICE_INDEX_MIN_CONFIDENT_SAMPLES = 3;
44
+ export class PriceIndexRepository extends BaseRepository {
45
+ constructor() {
46
+ super(PRICE_INDEX_COLLECTION);
47
+ }
48
+ async getForLeaf(leafId) {
49
+ return this.findById(leafId);
50
+ }
51
+ /**
52
+ * Write the current band for one leaf.
53
+ *
54
+ * `createWithId`/`update` rather than a blind `set`, matching every other
55
+ * rollup repository here — and `update` merges, so a leaf whose band for one
56
+ * listing type disappeared this run would keep the stale entry. Hence
57
+ * `byListingType` is replaced wholesale by passing the full map.
58
+ */
59
+ async setForLeaf(leafId, data) {
60
+ const payload = { currency: "INR", ...data, computedAt: new Date() };
61
+ const existing = await this.findById(leafId);
62
+ if (existing) {
63
+ await this.update(leafId, payload);
64
+ }
65
+ else {
66
+ await this.createWithId(leafId, payload);
67
+ }
68
+ }
69
+ /**
70
+ * Append today's point.
71
+ *
72
+ * Keyed on the date, so a re-run on the same day OVERWRITES rather than
73
+ * double-counting — the same idempotency `pageViewRollup`'s `throughDate`
74
+ * buys, achieved here by the document id instead of a cursor.
75
+ */
76
+ async setDailyPoint(leafId, date, byListingType) {
77
+ await this.db
78
+ .collection(PRICE_INDEX_COLLECTION)
79
+ .doc(leafId)
80
+ .collection(DAILY_SUBCOLLECTION)
81
+ .doc(date)
82
+ .set({ date, byListingType, computedAt: new Date() }, { merge: true });
83
+ }
84
+ }
85
+ export const priceIndexRepository = new PriceIndexRepository();