@mohasinac/appkit 4.47.0 → 4.48.1

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 (173) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
  38. package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
  103. package/dist/features/seller/components/SellerProductShell.js +2 -2
  104. package/dist/features/seller/repository/offer.repository.js +21 -6
  105. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  106. package/dist/features/seller/schemas/product-form.js +1 -1
  107. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  108. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  109. package/dist/features/server-errors/schemas/firestore.js +2 -0
  110. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  111. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  112. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  113. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  114. package/dist/features/stores/repository/store.repository.js +11 -3
  115. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  116. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  117. package/dist/features/tester/seed-data/authored/index.js +2 -0
  118. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  119. package/dist/index.d.ts +8 -2
  120. package/dist/index.js +1 -1
  121. package/dist/next/routing/route-map.d.ts +14 -0
  122. package/dist/next/routing/route-map.js +26 -0
  123. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  124. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  125. package/dist/repositories/index.d.ts +1 -0
  126. package/dist/repositories/index.js +4 -0
  127. package/dist/schemas/registry.d.ts +3 -3
  128. package/dist/security/index.d.ts +1 -1
  129. package/dist/security/index.js +1 -1
  130. package/dist/security/pii-schemas.d.ts +17 -0
  131. package/dist/security/pii-schemas.js +15 -7
  132. package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
  133. package/dist/seed/_helpers/category-content-generated.js +23 -7
  134. package/dist/seed/_helpers/category-models.d.ts +1 -1
  135. package/dist/seed/_helpers/category-models.js +25 -1
  136. package/dist/seed/_helpers/category-names.d.ts +54 -0
  137. package/dist/seed/_helpers/category-names.js +118 -0
  138. package/dist/seed/_helpers/character-features.d.ts +1 -1
  139. package/dist/seed/_helpers/character-features.js +5 -4
  140. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  141. package/dist/seed/categories-seed-data.js +55 -1
  142. package/dist/seed/grouped-listings-seed-data.js +2 -2
  143. package/dist/seed/products-art-seed-data.js +2 -1
  144. package/dist/seed/products-auctions-seed-data.js +42 -22
  145. package/dist/seed/products-classifieds-seed-data.js +21 -3
  146. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  147. package/dist/seed/products-live-items-seed-data.js +2 -1
  148. package/dist/seed/products-preorders-seed-data.js +26 -11
  149. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  150. package/dist/seed/products-standard-seed-data.js +26 -25
  151. package/dist/seed/products-stickers-seed-data.js +12 -11
  152. package/dist/server.d.ts +8 -0
  153. package/dist/server.js +15 -0
  154. package/dist/styles.css +113 -1
  155. package/dist/tailwind-utilities.css +1 -1
  156. package/dist/tokens/themes/sunset.js +17 -0
  157. package/dist/tokens/tokens.css +5 -0
  158. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  159. package/dist/ui/components/Breadcrumb.js +2 -2
  160. package/dist/ui/components/Breadcrumb.style.css +37 -0
  161. package/dist/ui/components/Details.d.ts +11 -1
  162. package/dist/ui/components/Details.js +2 -2
  163. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  164. package/dist/ui/components/ImageMosaic.js +46 -0
  165. package/dist/ui/components/ImageMosaic.style.css +69 -0
  166. package/dist/ui/components/SplitTile.d.ts +34 -0
  167. package/dist/ui/components/SplitTile.js +28 -0
  168. package/dist/ui/components/index.style.css +1 -0
  169. package/dist/ui/index.d.ts +4 -0
  170. package/dist/ui/index.js +4 -0
  171. package/dist/utils/search-txt-query.d.ts +77 -0
  172. package/dist/utils/search-txt-query.js +50 -28
  173. package/package.json +1 -1
@@ -11,23 +11,70 @@ export declare const MAX_CATEGORY_ASSIGNMENTS = 4;
11
11
  /**
12
12
  * 🛑 THE FLIP. `false` = the leaf/non-leaf rule REPORTS; `true` = it refuses.
13
13
  *
14
- * It ships `false` and is flipped only once the data is clean, and the reason is
15
- * a measurement: **70 of the 71 hand-authored products file at a non-leaf
16
- * today** and not one of them is a lot. Enforcing first would make the whole
17
- * catalogue unsaveable — and worse, it would make the very seed writes that fix
18
- * the data fail, because those also go through `deriveTaxonomy`.
14
+ * It ships `false` and is flipped only once the data is clean. Enforcing first
15
+ * would make the whole catalogue unsaveable — and worse, it would make the very
16
+ * seed writes that fix the data fail, because those also go through
17
+ * `deriveTaxonomy`.
19
18
  *
20
19
  * This is the G3a lesson, which cost a symmetric rule its credibility: the plan
21
20
  * specified "a lot takes a non-leaf while singles take leaves", measurement
22
21
  * rejected the second half, and the half-rule has been live since. The rule is
23
22
  * right; enforcing it before its data is not.
24
23
  *
25
- * Flip procedure, and it is a ratchet rather than a judgement call: run
26
- * `findLeafRuleOffenders` over every seeded row, confirm **0**, then set `true`.
27
- * A hand count is not the measurement — the earlier one said 58 of 70 and this
28
- * pass measured 70 of 71.
24
+ * ## 🛑 The offender count: FOUR answers, one measurement
25
+ *
26
+ * This comment asserted "**70 of the 71** hand-authored products file at a
27
+ * non-leaf, and not one of them is a lot". Both halves were wrong.
28
+ *
29
+ * | Claim | Where it came from |
30
+ * |---|---|
31
+ * | 58 of 70 | an early hand count |
32
+ * | 70 of 71 | this comment, until 2026-10-13 |
33
+ * | 0 | a first scripted run that measured NOTHING — see below |
34
+ * | **24** | `scripts/measure-leaf-offenders.mjs`, running the real rule |
35
+ *
36
+ * The real figure was **24** (23 `single-at-branch` + **1 `lot-at-leaf`**, so
37
+ * the "not one is a lot" half was false too), now **14** after re-filing the
38
+ * ten that had an exact existing leaf.
39
+ *
40
+ * The `0` is the instructive one: that script reimplemented
41
+ * `readAssignments`' precedence inline as "`categoryIds`, else `category`" —
42
+ * its first two branches only. The seed writes **neither**; it writes
43
+ * `categorySlugs`, and the real function takes its HEAD. So it found no
44
+ * assignments on any product, examined nothing, and reported the flip safe.
45
+ *
46
+ * ## Flip procedure — a ratchet, not a judgement call
47
+ *
48
+ * node scripts/measure-leaf-offenders.mjs # must print 0 and exit 0
49
+ *
50
+ * Never flip on a hand count, and never on a zero the script produced while
51
+ * reporting `unassigned === products` — it exits 2 for exactly that case now,
52
+ * because a zero out of a corpus nothing could be read from is not a clean
53
+ * result.
54
+ *
55
+ * ## ✅ FLIPPED 2026-10-14, on a measurement
56
+ *
57
+ * products : 71 · unassigned : 0 · categories : 1177 · offenders : 0
58
+ *
59
+ * `unassigned: 0` is the half that makes the zero mean something: every
60
+ * product resolved to a real assignment and every one of those satisfied the
61
+ * rule. Getting there took three kinds of fix, and the mix is worth knowing
62
+ * because it is NOT what the plan predicted:
63
+ *
64
+ * - **11 re-filed** to a leaf that already existed. Nine were also in the
65
+ * wrong GENERATION, which several products' own copy already contradicted.
66
+ * - **4 corpus rows promoted** out of `review` with verified facts and
67
+ * stated provenance (`VERIFIED_RESOLUTIONS` in `parse-bey-corpus.mjs`).
68
+ * - **10 fixtures retitled**, because they named products that do not
69
+ * exist — a Burst "B-215 Hells Scythe" (every HellsScythe is Beyblade X),
70
+ * a "Wizard Fafnir 4-60F" (a Burst layer with an X part code), a ring
71
+ * BOOSTER and a release WAVE, neither of which can ever have a model leaf.
72
+ *
73
+ * 🛑 If this ever needs to go back to `false`, the honest reason is "the data
74
+ * regressed", and the measurement will say so before the flag does. Re-run the
75
+ * script rather than reasoning about it.
29
76
  */
30
- export declare const ENFORCE_LEAF_FILING = false;
77
+ export declare const ENFORCE_LEAF_FILING = true;
31
78
  /** A category document, reduced to the fields assignment actually needs. */
32
79
  export interface ResolvedCategory {
33
80
  id: string;
@@ -73,8 +120,30 @@ export declare function readAssignments(input: {
73
120
  category?: string;
74
121
  categorySlugs?: string[];
75
122
  }): string[];
123
+ /**
124
+ * 🛑 THE LEAF RULE DOES NOT APPLY TO EVERY LISTING TYPE, and measuring the data
125
+ * is what showed that — the rule was wrong, not the catalogue.
126
+ *
127
+ * Of the 70 non-leaf singles, **31 are types that are not an instance of a
128
+ * named model at all**: 8 app/coaching redemption codes, 8 city classifieds
129
+ * (collections, kits, spare-part bundles), 6 sticker sheets, 5 art prints and
130
+ * 4 live animals or plants. "Beyblade X App — Starter Pack Code" is not a
131
+ * Dragoon G. A golden retriever puppy has no model leaf and never will.
132
+ *
133
+ * So the rule is scoped by the SAME predicate that already answers this
134
+ * question for the Parts tab: does a listing of this type HAVE a part
135
+ * composition — i.e. IS it the assembly, or does it merely depict one. Reusing
136
+ * it rather than writing a second list is the point: a tenth listing type must
137
+ * answer the question once, not twice, and two hand-written enumerations of one
138
+ * union is Root Cause #61.
139
+ *
140
+ * `classified` and `live` stay `true` there, and that is deliberate: a
141
+ * classified CAN be a single bey, and the seeded ones that are collections are
142
+ * `isLot: true`, which the lot branch already permits at a non-leaf.
143
+ */
144
+ export declare function leafRuleApplies(listingType: string | undefined): boolean;
76
145
  /** Which assignments break the leaf rule, and why. Reporting shape, no throw. */
77
- export declare function findLeafRuleOffenders(resolved: ResolvedCategory[], isLot: boolean): {
146
+ export declare function findLeafRuleOffenders(resolved: ResolvedCategory[], isLot: boolean, listingType?: string): {
78
147
  id: string;
79
148
  name?: string;
80
149
  reason: "lot-at-leaf" | "single-at-branch" | "not-indexed";
@@ -88,6 +157,8 @@ export declare function findLeafRuleOffenders(resolved: ResolvedCategory[], isLo
88
157
  */
89
158
  export declare function assertAssignmentShape(resolved: ResolvedCategory[], isLot: boolean, opts?: {
90
159
  enforceLeafFiling?: boolean;
160
+ /** Scopes the SINGLE half of the leaf rule — see `leafRuleApplies`. */
161
+ listingType?: string;
91
162
  warn?: (msg: string, meta?: object) => void;
92
163
  }): void;
93
164
  /**
@@ -47,6 +47,7 @@
47
47
  * @tag sideEffects:none
48
48
  */
49
49
  import { ConflictError, ValidationError } from "../../../errors/index.js";
50
+ import { LISTING_TYPE_HAS_COMPOSITION } from "../../parts/schemas/firestore.js";
50
51
  /**
51
52
  * 🛑 A CAP, because the closure is `array-contains`-indexed and Firestore's
52
53
  * `array-contains-any` takes at most 30 comparison values.
@@ -60,23 +61,70 @@ export const MAX_CATEGORY_ASSIGNMENTS = 4;
60
61
  /**
61
62
  * 🛑 THE FLIP. `false` = the leaf/non-leaf rule REPORTS; `true` = it refuses.
62
63
  *
63
- * It ships `false` and is flipped only once the data is clean, and the reason is
64
- * a measurement: **70 of the 71 hand-authored products file at a non-leaf
65
- * today** and not one of them is a lot. Enforcing first would make the whole
66
- * catalogue unsaveable — and worse, it would make the very seed writes that fix
67
- * the data fail, because those also go through `deriveTaxonomy`.
64
+ * It ships `false` and is flipped only once the data is clean. Enforcing first
65
+ * would make the whole catalogue unsaveable — and worse, it would make the very
66
+ * seed writes that fix the data fail, because those also go through
67
+ * `deriveTaxonomy`.
68
68
  *
69
69
  * This is the G3a lesson, which cost a symmetric rule its credibility: the plan
70
70
  * specified "a lot takes a non-leaf while singles take leaves", measurement
71
71
  * rejected the second half, and the half-rule has been live since. The rule is
72
72
  * right; enforcing it before its data is not.
73
73
  *
74
- * Flip procedure, and it is a ratchet rather than a judgement call: run
75
- * `findLeafRuleOffenders` over every seeded row, confirm **0**, then set `true`.
76
- * A hand count is not the measurement — the earlier one said 58 of 70 and this
77
- * pass measured 70 of 71.
74
+ * ## 🛑 The offender count: FOUR answers, one measurement
75
+ *
76
+ * This comment asserted "**70 of the 71** hand-authored products file at a
77
+ * non-leaf, and not one of them is a lot". Both halves were wrong.
78
+ *
79
+ * | Claim | Where it came from |
80
+ * |---|---|
81
+ * | 58 of 70 | an early hand count |
82
+ * | 70 of 71 | this comment, until 2026-10-13 |
83
+ * | 0 | a first scripted run that measured NOTHING — see below |
84
+ * | **24** | `scripts/measure-leaf-offenders.mjs`, running the real rule |
85
+ *
86
+ * The real figure was **24** (23 `single-at-branch` + **1 `lot-at-leaf`**, so
87
+ * the "not one is a lot" half was false too), now **14** after re-filing the
88
+ * ten that had an exact existing leaf.
89
+ *
90
+ * The `0` is the instructive one: that script reimplemented
91
+ * `readAssignments`' precedence inline as "`categoryIds`, else `category`" —
92
+ * its first two branches only. The seed writes **neither**; it writes
93
+ * `categorySlugs`, and the real function takes its HEAD. So it found no
94
+ * assignments on any product, examined nothing, and reported the flip safe.
95
+ *
96
+ * ## Flip procedure — a ratchet, not a judgement call
97
+ *
98
+ * node scripts/measure-leaf-offenders.mjs # must print 0 and exit 0
99
+ *
100
+ * Never flip on a hand count, and never on a zero the script produced while
101
+ * reporting `unassigned === products` — it exits 2 for exactly that case now,
102
+ * because a zero out of a corpus nothing could be read from is not a clean
103
+ * result.
104
+ *
105
+ * ## ✅ FLIPPED 2026-10-14, on a measurement
106
+ *
107
+ * products : 71 · unassigned : 0 · categories : 1177 · offenders : 0
108
+ *
109
+ * `unassigned: 0` is the half that makes the zero mean something: every
110
+ * product resolved to a real assignment and every one of those satisfied the
111
+ * rule. Getting there took three kinds of fix, and the mix is worth knowing
112
+ * because it is NOT what the plan predicted:
113
+ *
114
+ * - **11 re-filed** to a leaf that already existed. Nine were also in the
115
+ * wrong GENERATION, which several products' own copy already contradicted.
116
+ * - **4 corpus rows promoted** out of `review` with verified facts and
117
+ * stated provenance (`VERIFIED_RESOLUTIONS` in `parse-bey-corpus.mjs`).
118
+ * - **10 fixtures retitled**, because they named products that do not
119
+ * exist — a Burst "B-215 Hells Scythe" (every HellsScythe is Beyblade X),
120
+ * a "Wizard Fafnir 4-60F" (a Burst layer with an X part code), a ring
121
+ * BOOSTER and a release WAVE, neither of which can ever have a model leaf.
122
+ *
123
+ * 🛑 If this ever needs to go back to `false`, the honest reason is "the data
124
+ * regressed", and the measurement will say so before the flag does. Re-run the
125
+ * script rather than reasoning about it.
78
126
  */
79
- export const ENFORCE_LEAF_FILING = false;
127
+ export const ENFORCE_LEAF_FILING = true;
80
128
  /**
81
129
  * The authored assignment list, from whichever field the caller used.
82
130
  *
@@ -123,9 +171,42 @@ export function readAssignments(input) {
123
171
  }
124
172
  return out;
125
173
  }
174
+ /**
175
+ * 🛑 THE LEAF RULE DOES NOT APPLY TO EVERY LISTING TYPE, and measuring the data
176
+ * is what showed that — the rule was wrong, not the catalogue.
177
+ *
178
+ * Of the 70 non-leaf singles, **31 are types that are not an instance of a
179
+ * named model at all**: 8 app/coaching redemption codes, 8 city classifieds
180
+ * (collections, kits, spare-part bundles), 6 sticker sheets, 5 art prints and
181
+ * 4 live animals or plants. "Beyblade X App — Starter Pack Code" is not a
182
+ * Dragoon G. A golden retriever puppy has no model leaf and never will.
183
+ *
184
+ * So the rule is scoped by the SAME predicate that already answers this
185
+ * question for the Parts tab: does a listing of this type HAVE a part
186
+ * composition — i.e. IS it the assembly, or does it merely depict one. Reusing
187
+ * it rather than writing a second list is the point: a tenth listing type must
188
+ * answer the question once, not twice, and two hand-written enumerations of one
189
+ * union is Root Cause #61.
190
+ *
191
+ * `classified` and `live` stay `true` there, and that is deliberate: a
192
+ * classified CAN be a single bey, and the seeded ones that are collections are
193
+ * `isLot: true`, which the lot branch already permits at a non-leaf.
194
+ */
195
+ export function leafRuleApplies(listingType) {
196
+ if (!listingType)
197
+ return true;
198
+ return LISTING_TYPE_HAS_COMPOSITION[listingType] !== false;
199
+ }
126
200
  /** Which assignments break the leaf rule, and why. Reporting shape, no throw. */
127
- export function findLeafRuleOffenders(resolved, isLot) {
201
+ export function findLeafRuleOffenders(resolved, isLot, listingType) {
128
202
  const out = [];
203
+ /*
204
+ * The LOT half still applies to every type — filing a lot of twelve beys
205
+ * under one model's leaf is a false claim about the items whatever the
206
+ * listing type is. Only the SINGLE half is scoped.
207
+ */
208
+ if (!isLot && !leafRuleApplies(listingType))
209
+ return out;
129
210
  for (const c of resolved) {
130
211
  if (isLot) {
131
212
  if (c.isLeaf === true)
@@ -179,7 +260,7 @@ export function assertAssignmentShape(resolved, isLot, opts = {}) {
179
260
  throw new ValidationError(`${named} are in the same category tree, so a listing cannot be filed under both. ` +
180
261
  `Pick one, or pick the category above them that covers both.`);
181
262
  }
182
- const offenders = findLeafRuleOffenders(resolved, isLot);
263
+ const offenders = findLeafRuleOffenders(resolved, isLot, opts.listingType);
183
264
  if (offenders.length === 0)
184
265
  return;
185
266
  /*
@@ -190,7 +190,18 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
190
190
  * stock decrement or a status flip pays none of it.
191
191
  */
192
192
  private applyCategoryDefaults;
193
- /** The keys whose presence means a write could change the product's taxonomy. */
193
+ /**
194
+ * The keys whose presence means a write could change the product's taxonomy.
195
+ *
196
+ * 🛑 `categoryIds` is the ASSIGNED field — the one a human picks and the one
197
+ * `readAssignments` prefers over the `category` scalar. It was absent from
198
+ * this list, so a write naming only `categoryIds` skipped `deriveTaxonomy`
199
+ * entirely: the ordered closure, the `category` mirror and `categoryNames`
200
+ * were never re-derived, and `syncCategoryCounts` compared a document to
201
+ * itself and moved nothing. The write returned 200 and the product's own
202
+ * category pages did not change. Same shape as Root Cause #101 — a field
203
+ * accepted on the way in whose derivation never runs.
204
+ */
194
205
  private static readonly TAXONOMY_KEYS;
195
206
  update(id: string, data: Partial<ProductDocument>): Promise<ProductDocument>;
196
207
  /**
@@ -13,7 +13,7 @@ import { parseSearchTxtQuery } from "../../../utils/search-txt.js";
13
13
  // The shared search plan/refine the other eleven migrated collections use.
14
14
  // `planSearchTxt` carries the `empty` guard that keeps a punctuation-only query
15
15
  // from returning the whole catalogue.
16
- import { planSearchTxt, SEARCH_REFINE_WINDOW, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
16
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
17
17
  // One definition of "which fields feed searchTxt", shared with the seed
18
18
  // wrapper. Three copies existed and two were missing the same three fields.
19
19
  import { buildProductSearchTxt } from "../../../utils/search-txt-builders.js";
@@ -359,6 +359,7 @@ export class ProductRepository extends BaseRepository {
359
359
  if (resolved.length > 0) {
360
360
  assertAssignmentShape(resolved, input.isLot === true, {
361
361
  enforceLeafFiling: ENFORCE_LEAF_FILING,
362
+ listingType: input.listingType,
362
363
  warn: (msg, meta) => serverLogger.warn(msg, { productTitle: input.title, ...meta }),
363
364
  });
364
365
  /*
@@ -520,15 +521,20 @@ export class ProductRepository extends BaseRepository {
520
521
  return; // an exempt item has nothing to gross up
521
522
  out.price = grossFromTaxable(input.price, effectiveRate);
522
523
  /*
523
- * 🛑 `compareAtPrice` is deliberately NOT converted here, because it does
524
- * not exist. `SellerProductShell`'s pricing section lists it among its
525
- * `fields` and renders an input for it, but `ProductDocument` has no such
526
- * property — so whatever a seller types there is stripped by the schema and
527
- * never stored. A declared-but-unpersisted form field is its own defect
528
- * (and a strike-through price is a money figure, so it would need the same
529
- * gross-up the day it becomes real); logged rather than silently worked
530
- * around, since converting a field that is discarded would be theatre.
524
+ * 🛑 The strike-through price is grossed up TOO, now that it is a real
525
+ * field (F1 #25 — it was `compareAtPrice` in the form, `originalPrice` in
526
+ * both renderers, and nowhere in the document).
527
+ *
528
+ * It has to move with `price` or the discount it advertises is wrong: an
529
+ * ex-tax ₹1,000 "was" beside an inc-tax ₹1,050 "now" renders as a price
530
+ * RISE, and an ex-tax pair converted on one side only overstates the
531
+ * saving by the GST rate. The guard is the same one `price` uses — only a
532
+ * patch that carries the field is converted, so an unrelated save cannot
533
+ * re-convert an already-converted figure.
531
534
  */
535
+ if (input.originalPrice != null && input.originalPrice > 0) {
536
+ out.originalPrice = grossFromTaxable(input.originalPrice, effectiveRate);
537
+ }
532
538
  serverLogger.info("[gst] grossed up an ex-tax price for a pricesIncludeGst:false store", {
533
539
  storeId: input.storeId,
534
540
  rate: effectiveRate,
@@ -1196,7 +1202,11 @@ export class ProductRepository extends BaseRepository {
1196
1202
  if (searchTxt.length > 0) {
1197
1203
  // Firestore permits a single array operator per query, so when a search
1198
1204
  // and a category filter are both present the SEARCH takes the clause and
1199
- // the category is refined by the caller.
1205
+ // the category is refined by the caller — which, since the `also`
1206
+ // predicate in `list()`, is now true rather than merely asserted. This
1207
+ // comment claimed it for as long as the gate below has existed and no
1208
+ // caller did it, so a search inside a category returned the whole
1209
+ // catalogue.
1200
1210
  //
1201
1211
  // Two shapes here have each been reverted once and are both guarded by
1202
1212
  // scripts/audit-search-semantics.mjs:
@@ -1270,25 +1280,34 @@ export class ProductRepository extends BaseRepository {
1270
1280
  * `SEARCH_REFINE_WINDOW`, and when that saturates the result carries
1271
1281
  * `truncated` so the total renders as a floor rather than a false total.
1272
1282
  */
1273
- const isMultiTerm = plan.rest.length > 0;
1274
- const requestedPage = Math.max(1, Number(model?.page ?? 1) || 1);
1275
- const requestedPageSize = Math.max(1, Number(model?.pageSize ?? 20) || 20);
1276
- const result = await this.sieveQuery(
1277
1283
  /*
1278
- * The window is requested through the MODEL, not a new `SieveOptions`
1279
- * field — `sieveQuery` already derives its page from `model.page` /
1280
- * `model.pageSize`, so overriding them needs no API change and no second
1281
- * way to express a page size.
1284
+ * ── The category filter has to be refined for the SAME reason ───────────
1285
+ *
1286
+ * 🛑 `buildScopedQuery` gates `categoriesIn` on `searchTxt.length === 0`,
1287
+ * because Firestore permits one array operator per query and the search
1288
+ * term takes it. Its comment said "the category is refined by the caller"
1289
+ * — and NO caller did, for as long as the gate has existed. So searching
1290
+ * inside a category silently returned matches from the whole catalogue:
1291
+ * a plausible, populated, wrong grid with a 200 (Root Cause #62's family).
1292
+ *
1293
+ * This is that caller. The refine rides the same bounded window the
1294
+ * multi-term refine already uses, so the cost is one read either way and
1295
+ * there is no second pagination implementation to disagree with the first.
1282
1296
  */
1283
- isMultiTerm ? { ...model, page: 1, pageSize: SEARCH_REFINE_WINDOW } : model, ProductRepository.SIEVE_FIELDS, {
1297
+ const categoriesIn = opts?.categoriesIn ?? [];
1298
+ const needsCategoryRefine = plan.head !== null && categoriesIn.length > 0;
1299
+ const wanted = new Set(categoriesIn);
1300
+ /*
1301
+ * The window, the raised `maxPageSize` and the page slice are all one
1302
+ * decision, so they come from one helper — `beginSearchWindow`. Keeping
1303
+ * them hand-written here is what let the other ten repositories each get
1304
+ * two of the three.
1305
+ */
1306
+ const win = beginSearchWindow(model, plan, { alsoRefining: needsCategoryRefine });
1307
+ const result = await this.sieveQuery(win.model, ProductRepository.SIEVE_FIELDS, {
1284
1308
  baseQuery: this.buildScopedQuery(opts),
1285
1309
  defaultPageSize: 20,
1286
- /*
1287
- * 🛑 `maxPageSize` has to rise with the window or Sieve clamps the
1288
- * request back to 100 — and the window would silently be a page again,
1289
- * which is the original bug with a larger constant.
1290
- */
1291
- maxPageSize: isMultiTerm ? SEARCH_REFINE_WINDOW : 100,
1310
+ maxPageSize: win.maxPageSize(100),
1292
1311
  aliases: ProductRepository.FILTER_ALIASES,
1293
1312
  });
1294
1313
  // Only the most selective term became the `array-contains` clause, because
@@ -1303,14 +1322,15 @@ export class ProductRepository extends BaseRepository {
1303
1322
  // this page only. That is the known post-pagination-filter debt, not a new
1304
1323
  // invention — reporting the pre-filter total would promise pages that
1305
1324
  // render empty.
1306
- return refineSearchTxt(result, plan.rest, isMultiTerm
1307
- ? {
1308
- page: requestedPage,
1309
- pageSize: requestedPageSize,
1310
- // Saturated means more may exist beyond the window, so `total` is a
1311
- // FLOOR and `truncated` says so.
1312
- windowSaturated: (result.items?.length ?? 0) >= SEARCH_REFINE_WINDOW,
1313
- }
1325
+ /*
1326
+ * 🛑 The category predicate matches against the CLOSURE, not the leaf.
1327
+ * `categorySlugs` carries the full ancestor chain, so a category page
1328
+ * passing its own id matches every product in its subtree — the same
1329
+ * semantics the pushed-down `array-contains-any` has, which is what makes
1330
+ * the refined result identical to the un-searched one.
1331
+ */
1332
+ return win.finish(result, needsCategoryRefine
1333
+ ? (row) => (row.categorySlugs ?? []).some((slug) => wanted.has(slug))
1314
1334
  : undefined);
1315
1335
  }
1316
1336
  /**
@@ -1412,8 +1432,20 @@ export class ProductRepository extends BaseRepository {
1412
1432
  }
1413
1433
  }
1414
1434
  ProductRepository.CACHE_TTL_MS = 30000;
1415
- /** The keys whose presence means a write could change the product's taxonomy. */
1435
+ /**
1436
+ * The keys whose presence means a write could change the product's taxonomy.
1437
+ *
1438
+ * 🛑 `categoryIds` is the ASSIGNED field — the one a human picks and the one
1439
+ * `readAssignments` prefers over the `category` scalar. It was absent from
1440
+ * this list, so a write naming only `categoryIds` skipped `deriveTaxonomy`
1441
+ * entirely: the ordered closure, the `category` mirror and `categoryNames`
1442
+ * were never re-derived, and `syncCategoryCounts` compared a document to
1443
+ * itself and moved nothing. The write returned 200 and the product's own
1444
+ * category pages did not change. Same shape as Root Cause #101 — a field
1445
+ * accepted on the way in whose derivation never runs.
1446
+ */
1416
1447
  ProductRepository.TAXONOMY_KEYS = [
1448
+ "categoryIds",
1417
1449
  "categorySlugs",
1418
1450
  "category",
1419
1451
  "brand",
@@ -217,6 +217,28 @@ export interface ProductDocument extends BaseDocument {
217
217
  brand?: string;
218
218
  brandSlug?: string;
219
219
  price: number;
220
+ /**
221
+ * The strike-through "was ₹X", for a listing the seller is discounting.
222
+ *
223
+ * 🛑 F1 #25, and it was TWO OWNERS of one value rather than a missing field.
224
+ * `SellerProductShell` renders an input called `compareAtPrice` and the eight
225
+ * store edit pages read `(product as any).compareAtPrice` — a property no
226
+ * document has ever carried — while BOTH public renderers (`ProductGrid`,
227
+ * `ProductDetailPageView`) read `originalPrice` and hand it to `GatedPrice`
228
+ * as `originalAmount`, which is what draws the strike-through and derives
229
+ * the discount %. The consumer write schema already accepted `originalPrice`
230
+ * too. So the authoring half wrote one name, the rendering half read
231
+ * another, and the document declared neither: a seller set a sale price,
232
+ * saw a 200, and nothing ever showed it.
233
+ *
234
+ * Declared here under the name the renderers and the write schema already
235
+ * use, rather than adding a second field beside them (Root Cause #75).
236
+ *
237
+ * 🛑 It is a MONEY figure, so it is grossed up with `price` for a
238
+ * `pricesIncludeGst: false` store — a "was" price that silently meant
239
+ * ex-tax while the live price meant inc-tax would overstate the discount.
240
+ */
241
+ originalPrice?: number;
220
242
  currency: string;
221
243
  stockQuantity: number;
222
244
  availableQuantity: number;
@@ -648,8 +670,8 @@ export declare const ProductStatusValues: {
648
670
  export declare const PRODUCT_COLLECTION: "products";
649
671
  export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "categoryIds", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId", "physicalLocation.zone"];
650
672
  export declare const DEFAULT_PRODUCT_DATA: Partial<ProductDocument>;
651
- export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "categoryIds", "categoryIdNames", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "isLot", "lotSize", "unitLabel", "partGlossaryId", "colourway", "mouldVariant", "assembledFrom", "views7d", "views30d", "unitLabel", "partGlossaryId", "colourway", "mouldVariant", "assembledFrom", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
652
- export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "categoryIds", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "isLot", "lotSize", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "gstRate", "hsnCode", "authenticity", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
673
+ export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "categoryIds", "categoryIdNames", "subcategory", "brand", "price", "originalPrice", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "isLot", "lotSize", "unitLabel", "partGlossaryId", "colourway", "mouldVariant", "assembledFrom", "views7d", "views30d", "unitLabel", "partGlossaryId", "colourway", "mouldVariant", "assembledFrom", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
674
+ export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "categoryIds", "subcategory", "brand", "price", "originalPrice", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "isLot", "lotSize", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "gstRate", "hsnCode", "authenticity", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
653
675
  export type ProductCreateInput = Omit<ProductDocument, "id" | "createdAt" | "updatedAt" | "availableQuantity" | "bidCount" | "currentBid" | "auctionOriginalEndDate">;
654
676
  export type ProductUpdateInput = Partial<Pick<ProductDocument, (typeof PRODUCT_UPDATABLE_FIELDS)[number]>>;
655
677
  export type ProductAdminUpdateInput = Partial<Omit<ProductDocument, "id" | "createdAt">>;
@@ -84,6 +84,13 @@ export const PRODUCT_PUBLIC_FIELDS = [
84
84
  "subcategory",
85
85
  "brand",
86
86
  "price",
87
+ /*
88
+ * The strike-through "was ₹X". Public because that is the only place it
89
+ * means anything — `GatedPrice` renders it as `originalAmount` and derives
90
+ * the discount % from it, inside the guest gate, so it is published on
91
+ * exactly the same terms as `price` itself.
92
+ */
93
+ "originalPrice",
87
94
  "currency",
88
95
  "stockQuantity",
89
96
  "availableQuantity",
@@ -192,6 +199,9 @@ export const PRODUCT_UPDATABLE_FIELDS = [
192
199
  "subcategory",
193
200
  "brand",
194
201
  "price",
202
+ /* The strike-through "was" price — see the declaration on ProductDocument
203
+ * for why it is this name and not `compareAtPrice` (F1 #25). */
204
+ "originalPrice",
195
205
  "stockQuantity",
196
206
  "images",
197
207
  "status",
@@ -205,7 +215,9 @@ export const PRODUCT_UPDATABLE_FIELDS = [
205
215
  * `.passthrough()`, so a field absent from this list is dropped before it
206
216
  * reaches Firestore while the write still returns 200 — exactly how a
207
217
  * UI-created product lost its `categorySlugs` chain (Root Cause #101) and how
208
- * `compareAtPrice` is silently discarded today (F1 #25). */
218
+ * the strike-through price was silently discarded until F1 #25 was closed —
219
+ * the form wrote `compareAtPrice`, nothing declared it, and `originalPrice`
220
+ * (which both renderers read) was not in this list either. */
209
221
  "isLot",
210
222
  "lotSize",
211
223
  "pickupAddressId",
@@ -406,6 +406,7 @@ export declare const productItemSchema: z.ZodObject<{
406
406
  description?: string | undefined;
407
407
  keywords?: string[] | undefined;
408
408
  } | undefined;
409
+ originalPrice?: number | undefined;
409
410
  allowShipBeforeEmiComplete?: boolean | undefined;
410
411
  printMeta?: {
411
412
  size?: string | undefined;
@@ -459,7 +460,6 @@ export declare const productItemSchema: z.ZodObject<{
459
460
  }[] | undefined;
460
461
  prizeGithubFileUrl?: string | undefined;
461
462
  inStock?: boolean | undefined;
462
- originalPrice?: number | undefined;
463
463
  sellerAvatar?: string | undefined;
464
464
  stockCount?: number | undefined;
465
465
  attributes?: Record<string, string> | undefined;
@@ -545,6 +545,7 @@ export declare const productItemSchema: z.ZodObject<{
545
545
  description?: string | undefined;
546
546
  keywords?: string[] | undefined;
547
547
  } | undefined;
548
+ originalPrice?: number | undefined;
548
549
  allowShipBeforeEmiComplete?: boolean | undefined;
549
550
  printMeta?: {
550
551
  size?: string | undefined;
@@ -598,7 +599,6 @@ export declare const productItemSchema: z.ZodObject<{
598
599
  }[] | undefined;
599
600
  prizeGithubFileUrl?: string | undefined;
600
601
  inStock?: boolean | undefined;
601
- originalPrice?: number | undefined;
602
602
  sellerAvatar?: string | undefined;
603
603
  stockCount?: number | undefined;
604
604
  attributes?: Record<string, string> | undefined;
@@ -1,4 +1,4 @@
1
- import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
1
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
2
2
  import { buildCouponSearchTxt } from "../../../utils/search-txt-builders.js";
3
3
  import { normalizeError } from "../../../errors/normalize.js";
4
4
  import { serverLogger } from "../../../monitoring/server-logger.js";
@@ -500,12 +500,19 @@ export class CouponsRepository extends BaseRepository {
500
500
  if (plan.head) {
501
501
  baseQuery = baseQuery.where("searchTxt", "array-contains", plan.head);
502
502
  }
503
- const result = await this.sieveQuery(model, CouponsRepository.SIEVE_FIELDS, {
503
+ /*
504
+ * One bounded window, one raised `maxPageSize`, one page slice — see
505
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
506
+ * repositories each ended up with two of them, which reads as a working
507
+ * multi-word search and silently cannot reach page 2.
508
+ */
509
+ const win = beginSearchWindow(model, plan);
510
+ const result = await this.sieveQuery(win.model, CouponsRepository.SIEVE_FIELDS, {
504
511
  baseQuery,
505
512
  defaultPageSize: 50,
506
- maxPageSize: 200,
513
+ maxPageSize: win.maxPageSize(200),
507
514
  });
508
- return refineSearchTxt(result, plan.rest);
515
+ return win.finish(result);
509
516
  }
510
517
  /**
511
518
  * Cloud Functions: find active coupons whose validity period has expired.
@@ -4,7 +4,7 @@ import { REVIEW_MAX_RATING, REVIEW_MIN_RATING } from "../../../_internal/shared/
4
4
  import { decryptPiiFields, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security/index.js";
5
5
  import { createReviewId, REVIEW_COLLECTION, } from "../schemas/index.js";
6
6
  import { buildReviewSearchTxt } from "../../../utils/search-txt-builders.js";
7
- import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
7
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
8
8
  const REVIEW_FIELDS = {
9
9
  /** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
10
10
  SEARCH_TXT: "searchTxt",
@@ -362,14 +362,21 @@ class ReviewRepository extends BaseRepository {
362
362
  if (plan.head) {
363
363
  baseQuery = baseQuery.where(REVIEW_FIELDS.SEARCH_TXT, "array-contains", plan.head);
364
364
  }
365
- const result = await this.sieveQuery(model, ReviewRepository.SIEVE_FIELDS, {
365
+ /*
366
+ * One bounded window, one raised `maxPageSize`, one page slice — see
367
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
368
+ * repositories each ended up with two of them, which reads as a working
369
+ * multi-word search and silently cannot reach page 2.
370
+ */
371
+ const win = beginSearchWindow(model, plan);
372
+ const result = await this.sieveQuery(win.model, ReviewRepository.SIEVE_FIELDS, {
366
373
  baseQuery,
367
374
  defaultPageSize: 50,
368
- maxPageSize: 200,
375
+ maxPageSize: win.maxPageSize(200),
369
376
  });
370
377
  // Refine BEFORE decrypting: the refine reads `searchTxt`, which is never
371
378
  // encrypted, and decrypting rows that are about to be dropped is waste.
372
- return this.decryptSieveResult(refineSearchTxt(result, plan.rest));
379
+ return this.decryptSieveResult(win.finish(result));
373
380
  }
374
381
  }
375
382
  ReviewRepository.SIEVE_FIELDS = {