@mohasinac/appkit 4.62.1 → 4.63.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 (263) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +8 -29
  3. package/dist/_internal/client/features/layout/filterNavItems.d.ts +28 -0
  4. package/dist/_internal/client/features/layout/filterNavItems.js +35 -0
  5. package/dist/_internal/server/features/ads/booking.d.ts +118 -0
  6. package/dist/_internal/server/features/ads/booking.js +326 -0
  7. package/dist/_internal/server/features/checkout/actions.d.ts +51 -11
  8. package/dist/_internal/server/features/checkout/actions.js +335 -80
  9. package/dist/_internal/server/features/checkout/expand-order-items.d.ts +1 -2
  10. package/dist/_internal/server/features/checkout/expand-order-items.js +32 -0
  11. package/dist/_internal/server/features/checkout/freeze-parts.d.ts +50 -0
  12. package/dist/_internal/server/features/checkout/freeze-parts.js +94 -0
  13. package/dist/_internal/server/features/checkout/value-otp-gate.d.ts +55 -0
  14. package/dist/_internal/server/features/checkout/value-otp-gate.js +60 -0
  15. package/dist/_internal/server/features/dashboard/attention.d.ts +128 -0
  16. package/dist/_internal/server/features/dashboard/attention.js +183 -0
  17. package/dist/_internal/server/features/dashboard/data.d.ts +47 -0
  18. package/dist/_internal/server/features/dashboard/data.js +82 -0
  19. package/dist/_internal/server/features/inventory/reservations.d.ts +99 -0
  20. package/dist/_internal/server/features/inventory/reservations.js +273 -0
  21. package/dist/_internal/server/features/offers/destinations.d.ts +40 -0
  22. package/dist/_internal/server/features/offers/destinations.js +65 -0
  23. package/dist/_internal/server/features/orders/adapters.js +21 -0
  24. package/dist/_internal/server/features/orders/amend-delivery.d.ts +56 -0
  25. package/dist/_internal/server/features/orders/amend-delivery.js +124 -0
  26. package/dist/_internal/server/features/orders/balance.d.ts +89 -0
  27. package/dist/_internal/server/features/orders/balance.js +256 -0
  28. package/dist/_internal/server/features/pricing/actions.d.ts +58 -0
  29. package/dist/_internal/server/features/pricing/actions.js +144 -0
  30. package/dist/_internal/server/features/shipping/book-split.d.ts +86 -0
  31. package/dist/_internal/server/features/shipping/book-split.js +130 -0
  32. package/dist/_internal/server/features/shipping/book.d.ts +61 -0
  33. package/dist/_internal/server/features/shipping/book.js +176 -0
  34. package/dist/_internal/server/features/shipping/courier-block.js +26 -1
  35. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  36. package/dist/_internal/server/features/site-settings/adapters.js +14 -0
  37. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  38. package/dist/_internal/server/features/stores/adapters.js +14 -0
  39. package/dist/_internal/server/functions/scheduled.d.ts +2 -1
  40. package/dist/_internal/server/functions/scheduled.js +57 -1
  41. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +2 -0
  42. package/dist/_internal/server/jobs/core/jobRunners.js +45 -0
  43. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +4 -1
  44. package/dist/_internal/server/jobs/core/reservationExpiry.d.ts +17 -0
  45. package/dist/_internal/server/jobs/core/reservationExpiry.js +40 -0
  46. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +15 -0
  47. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  48. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  49. package/dist/_internal/server/jobs/handlers/reservationExpiry.d.ts +2 -0
  50. package/dist/_internal/server/jobs/handlers/reservationExpiry.js +2 -0
  51. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  52. package/dist/_internal/shared/actions/action-registry.js +268 -0
  53. package/dist/_internal/shared/checkout/order-math.d.ts +31 -2
  54. package/dist/_internal/shared/checkout/order-math.js +22 -36
  55. package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
  56. package/dist/_internal/shared/checkout/rules/index.js +1 -1
  57. package/dist/_internal/shared/checkout/rules/payment-gate.d.ts +18 -0
  58. package/dist/_internal/shared/checkout/rules/payment-gate.js +25 -0
  59. package/dist/_internal/shared/checkout/split.d.ts +0 -12
  60. package/dist/_internal/shared/checkout/split.js +22 -2
  61. package/dist/_internal/shared/content/disclaimers.js +7 -7
  62. package/dist/_internal/shared/content/published-values.d.ts +6 -1
  63. package/dist/_internal/shared/content/published-values.js +8 -0
  64. package/dist/_internal/shared/features/admin/integrations.d.ts +32 -2
  65. package/dist/_internal/shared/features/admin/integrations.js +19 -9
  66. package/dist/_internal/shared/features/dashboard/hub-sections.d.ts +72 -0
  67. package/dist/_internal/shared/features/dashboard/hub-sections.js +75 -0
  68. package/dist/_internal/shared/features/emi/choice.d.ts +98 -0
  69. package/dist/_internal/shared/features/emi/choice.js +69 -0
  70. package/dist/_internal/shared/features/emi/group.d.ts +84 -0
  71. package/dist/_internal/shared/features/emi/group.js +126 -0
  72. package/dist/_internal/shared/features/ledger/posting.d.ts +107 -0
  73. package/dist/_internal/shared/features/ledger/posting.js +165 -0
  74. package/dist/_internal/shared/features/ledger/reports.d.ts +76 -0
  75. package/dist/_internal/shared/features/ledger/reports.js +127 -0
  76. package/dist/_internal/shared/features/ledger/rules.d.ts +170 -0
  77. package/dist/_internal/shared/features/ledger/rules.js +221 -0
  78. package/dist/_internal/shared/features/offers/coordination.d.ts +122 -0
  79. package/dist/_internal/shared/features/offers/coordination.js +80 -0
  80. package/dist/_internal/shared/features/orders/delivery-snapshot.d.ts +114 -0
  81. package/dist/_internal/shared/features/orders/delivery-snapshot.js +116 -0
  82. package/dist/_internal/shared/features/payouts/pay-period.d.ts +91 -0
  83. package/dist/_internal/shared/features/payouts/pay-period.js +104 -0
  84. package/dist/_internal/shared/features/points/accepts.d.ts +1 -8
  85. package/dist/_internal/shared/features/points/accepts.js +12 -6
  86. package/dist/_internal/shared/features/pricing/rate-registry.d.ts +150 -0
  87. package/dist/_internal/shared/features/pricing/rate-registry.js +246 -0
  88. package/dist/_internal/shared/features/shipping/parcel.d.ts +123 -0
  89. package/dist/_internal/shared/features/shipping/parcel.js +139 -0
  90. package/dist/_internal/shared/features/site-settings/fees.d.ts +16 -8
  91. package/dist/_internal/shared/features/site-settings/fees.js +12 -8
  92. package/dist/_internal/shared/fees/explain.d.ts +81 -0
  93. package/dist/_internal/shared/fees/explain.js +101 -0
  94. package/dist/_internal/shared/history/index.d.ts +1 -1
  95. package/dist/_internal/shared/history/index.js +1 -1
  96. package/dist/_internal/shared/history/types.d.ts +7 -1
  97. package/dist/_internal/shared/history/types.js +25 -0
  98. package/dist/client.d.ts +11 -1
  99. package/dist/client.js +13 -0
  100. package/dist/constants/api-endpoints.d.ts +72 -0
  101. package/dist/constants/api-endpoints.js +41 -0
  102. package/dist/constants/field-names.d.ts +41 -0
  103. package/dist/constants/field-names.js +47 -0
  104. package/dist/features/account/components/UserOrdersView.js +28 -2
  105. package/dist/features/admin/components/AdminIntegrationsView.d.ts +55 -0
  106. package/dist/features/admin/components/AdminIntegrationsView.js +74 -0
  107. package/dist/features/admin/components/AdminOrderEditorView.d.ts +16 -1
  108. package/dist/features/admin/components/AdminOrderEditorView.js +72 -3
  109. package/dist/features/admin/components/AdminOrdersView.js +20 -1
  110. package/dist/features/admin/schemas/firestore.d.ts +32 -0
  111. package/dist/features/ads/schemas/booking-write.d.ts +157 -0
  112. package/dist/features/ads/schemas/booking-write.js +88 -0
  113. package/dist/features/ads/schemas/creative-form.d.ts +18 -0
  114. package/dist/features/ads/schemas/creative-form.js +51 -0
  115. package/dist/features/ads/schemas/firestore.d.ts +140 -0
  116. package/dist/features/ads/schemas/firestore.js +109 -0
  117. package/dist/features/auctions/repository/bid.repository.js +3 -1
  118. package/dist/features/audit-log/schemas/firestore.d.ts +110 -0
  119. package/dist/features/audit-log/schemas/firestore.js +74 -0
  120. package/dist/features/auth/actions/profile-actions.d.ts +69 -1
  121. package/dist/features/auth/actions/profile-actions.js +62 -1
  122. package/dist/features/auth/schemas/firestore.d.ts +16 -2
  123. package/dist/features/auth/schemas/firestore.js +2 -1
  124. package/dist/features/blog/actions/blog-actions.d.ts +6 -0
  125. package/dist/features/blog/actions/blog-actions.js +17 -2
  126. package/dist/features/blog/api/route.js +4 -1
  127. package/dist/features/blog/columns/index.js +5 -0
  128. package/dist/features/blog/components/BlogIndexPageView.js +2 -1
  129. package/dist/features/blog/repository/blog.repository.d.ts +4 -0
  130. package/dist/features/blog/repository/blog.repository.js +5 -0
  131. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  132. package/dist/features/blog/schemas/blog-form.js +1 -0
  133. package/dist/features/blog/schemas/firestore.d.ts +38 -1
  134. package/dist/features/blog/schemas/firestore.js +23 -0
  135. package/dist/features/blog/schemas/index.d.ts +6 -0
  136. package/dist/features/blog/schemas/index.js +2 -0
  137. package/dist/features/blog/types/index.d.ts +2 -0
  138. package/dist/features/cart/components/CartPriceBreakdown.d.ts +12 -1
  139. package/dist/features/cart/components/CartPriceBreakdown.js +24 -5
  140. package/dist/features/cart/repository/cart.repository.d.ts +14 -0
  141. package/dist/features/cart/repository/cart.repository.js +20 -0
  142. package/dist/features/cart/schemas/firestore.d.ts +24 -0
  143. package/dist/features/dashboard/components/DashboardHubView.d.ts +83 -0
  144. package/dist/features/dashboard/components/DashboardHubView.js +56 -0
  145. package/dist/features/dashboard/components/DashboardSectionsClient.d.ts +23 -0
  146. package/dist/features/dashboard/components/DashboardSectionsClient.js +66 -0
  147. package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.d.ts +55 -0
  148. package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.js +175 -0
  149. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +24 -0
  150. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +28 -0
  151. package/dist/features/dispute-reasons/schemas/firestore.d.ts +3 -5
  152. package/dist/features/events/components/EventSaleBanner.js +1 -1
  153. package/dist/features/faq/components/FAQPageContent.js +2 -2
  154. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  155. package/dist/features/homepage/schemas/index.d.ts +8 -8
  156. package/dist/features/inventory/schemas/stock-reservation.d.ts +114 -0
  157. package/dist/features/inventory/schemas/stock-reservation.js +103 -0
  158. package/dist/features/layout/AppLayoutShell.d.ts +9 -1
  159. package/dist/features/layout/AppLayoutShell.js +12 -4
  160. package/dist/features/layout/BottomActions.js +2 -1
  161. package/dist/features/layout/BottomNavbar.d.ts +10 -2
  162. package/dist/features/layout/SideActionDock.js +52 -2
  163. package/dist/features/layout/TitleBarLayout.js +13 -4
  164. package/dist/features/layout/TitleBarLayout.style.css +59 -0
  165. package/dist/features/layout/useCtaReveal.d.ts +16 -1
  166. package/dist/features/layout/useCtaReveal.js +17 -26
  167. package/dist/features/ledger/repository/journal.repository.d.ts +110 -0
  168. package/dist/features/ledger/repository/journal.repository.js +312 -0
  169. package/dist/features/ledger/schemas/expenses.d.ts +87 -0
  170. package/dist/features/ledger/schemas/expenses.js +27 -0
  171. package/dist/features/ledger/schemas/firestore.d.ts +183 -0
  172. package/dist/features/ledger/schemas/firestore.js +69 -0
  173. package/dist/features/orders/components/BalancePaymentPanel.d.ts +12 -0
  174. package/dist/features/orders/components/BalancePaymentPanel.js +94 -0
  175. package/dist/features/orders/components/RaiseDisputeForm.js +19 -11
  176. package/dist/features/orders/repository/orders.repository.d.ts +28 -1
  177. package/dist/features/orders/repository/orders.repository.js +60 -6
  178. package/dist/features/orders/schemas/balance.d.ts +40 -0
  179. package/dist/features/orders/schemas/balance.js +47 -0
  180. package/dist/features/orders/schemas/firestore.d.ts +202 -0
  181. package/dist/features/orders/schemas/firestore.js +43 -0
  182. package/dist/features/orders/schemas/index.d.ts +2 -2
  183. package/dist/features/orders/types/index.d.ts +21 -0
  184. package/dist/features/payments/repository/payout.repository.js +3 -2
  185. package/dist/features/payments/schemas/firestore.d.ts +92 -1
  186. package/dist/features/payments/schemas/firestore.js +23 -0
  187. package/dist/features/pricing/components/AdminPricingView.d.ts +13 -0
  188. package/dist/features/pricing/components/AdminPricingView.js +157 -0
  189. package/dist/features/products/schemas/firestore.d.ts +26 -1
  190. package/dist/features/products/schemas/firestore.js +2 -0
  191. package/dist/features/products/schemas/index.d.ts +2 -2
  192. package/dist/features/reviews/repository/reviews.repository.d.ts +14 -0
  193. package/dist/features/reviews/repository/reviews.repository.js +16 -0
  194. package/dist/features/scams/repository/scammer.repository.js +2 -1
  195. package/dist/features/scams/schemas/firestore.d.ts +2 -1
  196. package/dist/features/scams/schemas/firestore.js +2 -1
  197. package/dist/features/seller/actions/offer-actions.js +56 -0
  198. package/dist/features/seller/actions/seller-actions.d.ts +1 -1
  199. package/dist/features/seller/actions/seller-actions.js +4 -0
  200. package/dist/features/seller/components/DispatchForm.d.ts +11 -0
  201. package/dist/features/seller/components/DispatchForm.js +190 -0
  202. package/dist/features/seller/components/SellerOrdersView.js +94 -2
  203. package/dist/features/seller/components/index.d.ts +1 -0
  204. package/dist/features/seller/components/index.js +1 -0
  205. package/dist/features/seller/repository/offer.repository.js +4 -3
  206. package/dist/features/seller/schemas/firestore.d.ts +23 -2
  207. package/dist/features/seller/schemas/firestore.js +3 -2
  208. package/dist/features/seller/schemas/index.d.ts +4 -4
  209. package/dist/features/seller/schemas/order-forms.d.ts +61 -4
  210. package/dist/features/seller/schemas/order-forms.js +41 -8
  211. package/dist/features/shipments/schemas/firestore.d.ts +25 -0
  212. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +39 -0
  213. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +55 -0
  214. package/dist/features/shipping-boxes/schemas/firestore.d.ts +2 -4
  215. package/dist/features/stores/repository/store.repository.d.ts +18 -0
  216. package/dist/features/stores/repository/store.repository.js +27 -1
  217. package/dist/features/stores/schemas/firestore.d.ts +41 -0
  218. package/dist/features/stores/schemas/firestore.js +20 -0
  219. package/dist/features/stores/schemas/index.d.ts +6 -6
  220. package/dist/features/support/repository/support.repository.js +72 -6
  221. package/dist/features/support/schemas/firestore.d.ts +17 -0
  222. package/dist/features/support/schemas/index.d.ts +2 -2
  223. package/dist/index.d.ts +5 -0
  224. package/dist/index.js +19 -0
  225. package/dist/next/routing/route-map.d.ts +92 -0
  226. package/dist/next/routing/route-map.js +53 -0
  227. package/dist/react/contexts/SessionContext.d.ts +17 -1
  228. package/dist/schemas/registry.d.ts +22 -16
  229. package/dist/security/pii-schemas.d.ts +1 -1
  230. package/dist/security/pii-schemas.js +45 -0
  231. package/dist/server-entry.d.ts +1 -1
  232. package/dist/server-entry.js +10 -1
  233. package/dist/server.d.ts +29 -1
  234. package/dist/server.js +158 -0
  235. package/dist/styles.css +349 -1
  236. package/dist/tailwind-utilities.css +1 -1
  237. package/dist/ui/components/Button.d.ts +10 -1
  238. package/dist/ui/components/Button.js +25 -3
  239. package/dist/ui/components/Chart.d.ts +63 -0
  240. package/dist/ui/components/Chart.js +169 -0
  241. package/dist/ui/components/Chart.style.css +214 -0
  242. package/dist/ui/components/Disclaimer.js +10 -1
  243. package/dist/ui/components/OverflowRow.js +54 -2
  244. package/dist/ui/components/OverflowRow.style.css +38 -0
  245. package/dist/ui/components/RichTextEditor.js +99 -8
  246. package/dist/ui/components/chart-spec.d.ts +68 -0
  247. package/dist/ui/components/chart-spec.js +140 -0
  248. package/dist/ui/components/index.style.css +1 -0
  249. package/dist/ui/components/surface-tokens.d.ts +1 -1
  250. package/dist/ui/components/surface-tokens.js +9 -0
  251. package/dist/ui/components/toolbar-collapse.d.ts +13 -0
  252. package/dist/ui/components/toolbar-collapse.js +10 -1
  253. package/dist/ui/index.d.ts +6 -0
  254. package/dist/ui/index.js +3 -0
  255. package/dist/ui/rich-text/RichText.d.ts +10 -1
  256. package/dist/ui/rich-text/RichText.js +33 -2
  257. package/dist/ui/rich-text/RichText.style.css +35 -0
  258. package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
  259. package/dist/ui/rich-text/RichTextRenderer.js +34 -2
  260. package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
  261. package/dist/ui/rich-text/chart-blocks.js +87 -0
  262. package/package.json +1 -1
  263. package/scripts/seed-cli.mjs +3 -0
@@ -19,8 +19,7 @@
19
19
  */
20
20
  import type { CartItemDocument } from "../../../../features/cart/schemas/firestore";
21
21
  import type { ProductDocument } from "../../../../features/products/schemas/firestore";
22
- import type { OrderDocumentItem } from "../../../../features/orders/schemas/firestore";
23
- /** A row before rule decoration — the shape `decorateOrderItem` receives. */
22
+ import { type OrderDocumentItem } from "../../../../features/orders/schemas/firestore";
24
23
  export type BaseOrderLine = Omit<OrderDocumentItem, "listingType">;
25
24
  /**
26
25
  * Expand one cart line into the order rows it should produce.
@@ -17,8 +17,35 @@
17
17
  * The buyer's receipt is unaffected: `groupOrderItemsByLine` collapses these
18
18
  * rows back under one header, which it was already written to do.
19
19
  */
20
+ import { ORDER_ITEM_DESCRIPTION_MAX, ORDER_ITEM_SPECIFICATIONS_MAX, } from "../../../../features/orders/schemas/firestore.js";
20
21
  import { lineTotalFor, allocateAcrossMembers } from "../../../shared/checkout/order-math.js";
21
22
  import { isFinalSale } from "../../../../features/products/constants/final-sale.js";
23
+ function listingSnapshot(p) {
24
+ if (!p)
25
+ return {};
26
+ /*
27
+ * 🛑 A named Pick, not `Record<string, unknown>`. A bag of unknowns
28
+ * type-checks against anything, so a misspelled key — `descriptionTruncted`
29
+ * — would compile, spread onto the order line, and simply never be read.
30
+ * The field would look set and the truncation flag would be missing from
31
+ * every row (`audit-unknown-leakage` blocks exactly this).
32
+ */
33
+ const out = {};
34
+ const desc = typeof p.description === "string" ? p.description.trim() : "";
35
+ if (desc) {
36
+ out.description = desc.slice(0, ORDER_ITEM_DESCRIPTION_MAX);
37
+ if (desc.length > ORDER_ITEM_DESCRIPTION_MAX)
38
+ out.descriptionTruncated = true;
39
+ }
40
+ if (p.specifications?.length) {
41
+ out.specifications = p.specifications.slice(0, ORDER_ITEM_SPECIFICATIONS_MAX).map((x) => ({
42
+ name: x.name,
43
+ value: x.value,
44
+ ...(x.unit ? { unit: x.unit } : {}),
45
+ }));
46
+ }
47
+ return out;
48
+ }
22
49
  /**
23
50
  * Expand one cart line into the order rows it should produce.
24
51
  *
@@ -89,6 +116,8 @@ export function expandCartLineToOrderRows(item, representative, productById) {
89
116
  ...(representative.condition ? { condition: representative.condition } : {}),
90
117
  // I9 — the physical variant, for the price index's colourway slice.
91
118
  ...(representative.colourway ? { colourway: representative.colourway } : {}),
119
+ /* Part 14 — what the listing SAID, which is what a dispute argues over. */
120
+ ...listingSnapshot(representative),
92
121
  // Legacy collapsed bundle rows (no groupMembers) keep their old shape so
93
122
  // orders already in flight and their UI stay untouched.
94
123
  ...(item.bundleCategorySlug && item.bundleProductIds
@@ -146,6 +175,9 @@ export function expandCartLineToOrderRows(item, representative, productById) {
146
175
  // blended condition would describe neither.
147
176
  ...(product?.condition ? { condition: product.condition } : {}),
148
177
  ...(product?.colourway ? { colourway: product.colourway } : {}),
178
+ /* Per MEMBER, same as condition above: a bundle can mix a sealed item
179
+ * with a loose one, and one blended description would describe neither. */
180
+ ...listingSnapshot(product),
149
181
  ...(gstRate != null ? { gstRate, ...(hsnCode ? { hsnCode } : {}) } : {}),
150
182
  };
151
183
  });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Freezing a composed listing's PARTS onto its order line — Part 14.
3
+ *
4
+ * ## 🛑 Why this is a separate pass, and not inside `expand-order-items`
5
+ *
6
+ * `expandCartLineToOrderRows` is PURE: it takes a product map and returns
7
+ * rows, with no I/O. Composition is not on the product — it lives in
8
+ * `modelCompositions`, keyed by the model's CATEGORY leaf (a named model *is*
9
+ * a category leaf, per the parts schema). So freezing it needs a read, and a
10
+ * read inside that function would make the one piece of checkout arithmetic
11
+ * that can be reasoned about locally depend on Firestore.
12
+ *
13
+ * ## 🛑 Bounded, and usually free
14
+ *
15
+ * Two gates, in this order, because the first one is what keeps the budget:
16
+ *
17
+ * 1. **`LISTING_TYPE_HAS_COMPOSITION`** — most listing types have none, so a
18
+ * typical order does zero reads. This is checked before anything else.
19
+ * 2. **Distinct category leaves**, capped. Several lines of the same model
20
+ * share one read; `getCompositionsForCategory` is `React.cache`-wrapped, so
21
+ * a repeat within one request is free anyway.
22
+ *
23
+ * Rule #6 budgets ~3 sequential round trips for a server action. The cap is
24
+ * what stops a 20-line order of distinct models turning checkout into twenty.
25
+ *
26
+ * ## 🛑 Failure is SILENT BY DESIGN here, which is unusual and deliberate
27
+ *
28
+ * `getCompositionsForCategory` already swallows and warns. A composition we
29
+ * could not read must never fail a checkout: the buyer is paying, the parts
30
+ * list is descriptive, and an order placed without it is complete in every
31
+ * way that matters. The absence is recoverable — the live category still has
32
+ * the data — while a refused payment is not.
33
+ */
34
+ import type { OrderDocumentItem } from "../../../../features/orders/schemas/firestore";
35
+ /**
36
+ * How many distinct models one order may resolve composition for.
37
+ *
38
+ * Past this the order is a bulk purchase and the parts list stops being the
39
+ * thing anyone disputes. Exceeding it drops the extra lines' parts rather
40
+ * than failing — see the header.
41
+ */
42
+ export declare const COMPOSITION_LOOKUP_MAX = 8;
43
+ /**
44
+ * Attach `parts` to every line whose listing type has a composition.
45
+ *
46
+ * Returns a NEW array; the input rows are not mutated, because they are built
47
+ * by a pure function and keeping them pure is what lets that function be
48
+ * reasoned about on its own.
49
+ */
50
+ export declare function freezePartsOnItems(items: readonly OrderDocumentItem[]): Promise<OrderDocumentItem[]>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Freezing a composed listing's PARTS onto its order line — Part 14.
3
+ *
4
+ * ## 🛑 Why this is a separate pass, and not inside `expand-order-items`
5
+ *
6
+ * `expandCartLineToOrderRows` is PURE: it takes a product map and returns
7
+ * rows, with no I/O. Composition is not on the product — it lives in
8
+ * `modelCompositions`, keyed by the model's CATEGORY leaf (a named model *is*
9
+ * a category leaf, per the parts schema). So freezing it needs a read, and a
10
+ * read inside that function would make the one piece of checkout arithmetic
11
+ * that can be reasoned about locally depend on Firestore.
12
+ *
13
+ * ## 🛑 Bounded, and usually free
14
+ *
15
+ * Two gates, in this order, because the first one is what keeps the budget:
16
+ *
17
+ * 1. **`LISTING_TYPE_HAS_COMPOSITION`** — most listing types have none, so a
18
+ * typical order does zero reads. This is checked before anything else.
19
+ * 2. **Distinct category leaves**, capped. Several lines of the same model
20
+ * share one read; `getCompositionsForCategory` is `React.cache`-wrapped, so
21
+ * a repeat within one request is free anyway.
22
+ *
23
+ * Rule #6 budgets ~3 sequential round trips for a server action. The cap is
24
+ * what stops a 20-line order of distinct models turning checkout into twenty.
25
+ *
26
+ * ## 🛑 Failure is SILENT BY DESIGN here, which is unusual and deliberate
27
+ *
28
+ * `getCompositionsForCategory` already swallows and warns. A composition we
29
+ * could not read must never fail a checkout: the buyer is paying, the parts
30
+ * list is descriptive, and an order placed without it is complete in every
31
+ * way that matters. The absence is recoverable — the live category still has
32
+ * the data — while a refused payment is not.
33
+ */
34
+ import { getCompositionsForCategory } from "../parts/data.js";
35
+ import { LISTING_TYPE_HAS_COMPOSITION } from "../../../../features/parts/schemas/firestore.js";
36
+ /**
37
+ * How many distinct models one order may resolve composition for.
38
+ *
39
+ * Past this the order is a bulk purchase and the parts list stops being the
40
+ * thing anyone disputes. Exceeding it drops the extra lines' parts rather
41
+ * than failing — see the header.
42
+ */
43
+ export const COMPOSITION_LOOKUP_MAX = 8;
44
+ /**
45
+ * Attach `parts` to every line whose listing type has a composition.
46
+ *
47
+ * Returns a NEW array; the input rows are not mutated, because they are built
48
+ * by a pure function and keeping them pure is what lets that function be
49
+ * reasoned about on its own.
50
+ */
51
+ export async function freezePartsOnItems(items) {
52
+ const eligible = items.filter((i) => i.listingType && LISTING_TYPE_HAS_COMPOSITION[i.listingType] && i.categorySlug);
53
+ if (eligible.length === 0)
54
+ return [...items];
55
+ const leaves = [...new Set(eligible.map((i) => i.categorySlug))].slice(0, COMPOSITION_LOOKUP_MAX);
56
+ const byLeaf = new Map();
57
+ await Promise.all(leaves.map(async (leaf) => {
58
+ const comps = await getCompositionsForCategory(leaf);
59
+ /*
60
+ * One category can carry several compositions (a model released in
61
+ * variants). Flattened and de-duplicated by code: the order records
62
+ * which parts were CLAIMED, and two variants naming the same ring is
63
+ * one fact, not two.
64
+ */
65
+ const seen = new Map();
66
+ for (const c of comps) {
67
+ /*
68
+ * 🛑 `slots`, not `parts`. The first draft read `c.parts` — a field
69
+ * `ModelCompositionDocument` does not have — which would have
70
+ * silently frozen an empty list onto every composed line while every
71
+ * other part of the flow behaved correctly. Measured against the
72
+ * schema before shipping, not after.
73
+ */
74
+ for (const slot of c.slots ?? []) {
75
+ /*
76
+ * `slotKey` is the stable identity (`partId` is absent whenever a
77
+ * part did not resolve to a glossary row, which is common in the
78
+ * corpus). `partName` is what a human reads in a dispute.
79
+ */
80
+ const code = String(slot.partId ?? slot.slotKey ?? "");
81
+ if (!code || seen.has(code))
82
+ continue;
83
+ seen.set(code, String(slot.partName ?? slot.slotLabel ?? code));
84
+ }
85
+ }
86
+ if (seen.size > 0) {
87
+ byLeaf.set(leaf, [...seen].map(([code, name]) => ({ code, name })));
88
+ }
89
+ }));
90
+ return items.map((i) => {
91
+ const parts = i.categorySlug ? byLeaf.get(i.categorySlug) : undefined;
92
+ return parts?.length ? { ...i, parts } : i;
93
+ });
94
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The high-value OTP gate — one implementation, taking the amount it judges.
3
+ *
4
+ * ## Why this exists as a function rather than two `if`s
5
+ *
6
+ * There were two hand-rolled copies: one in `createOrderForGroup`'s lane
7
+ * block and one in `POST /api/payment/create-order`. They already disagreed
8
+ * on what they measured — the first summed `price × quantity` over the cart,
9
+ * the second used the gateway route's own `subtotalRs` — and neither could
10
+ * be reused by anything else, because the amount was computed inline rather
11
+ * than passed in. That is Root Cause #75's shape exactly: a rule reachable
12
+ * only by copy-paste gets copied, and a copy made before a branch was added
13
+ * never learns about it.
14
+ *
15
+ * ## 🛑 It takes an AMOUNT, and that is the Part 6 prerequisite
16
+ *
17
+ * `otpCheckoutThreshold` is per CHECKOUT, so three ₹4,000 checkouts clear
18
+ * ₹12,000 with no OTP — and splitting an order is precisely what a shared
19
+ * purchase does. The gate therefore cannot keep deriving its own figure from
20
+ * whatever cart happens to be in scope; the caller has to say what sum is
21
+ * being authorised. For an ordinary checkout that is the cart; for an
22
+ * N-party arrangement it is the ARRANGEMENT total, charged across several
23
+ * payments that are individually under the line.
24
+ *
25
+ * Passing the amount in is the whole change. There is no arrangement yet to
26
+ * pass one, which is why this lands as a prerequisite rather than as a
27
+ * feature — but the gate can no longer be dodged by a caller that did not
28
+ * exist when the rule was written.
29
+ */
30
+ export interface CheckoutValueOtpGateInput {
31
+ uid: string;
32
+ /** The sum being authorised, in rupees. See the header: the CALLER decides. */
33
+ amount: number;
34
+ /** `siteSettings.payment.otpCheckoutThreshold`. Absent or ≤ 0 disables the gate. */
35
+ threshold: number | undefined;
36
+ /**
37
+ * 🛑 COD is exempt, and that is a real decision rather than an oversight:
38
+ * a physical hand-off at the door is its own verification, and nothing has
39
+ * been captured when the gate would fire. Pass the method so the exemption
40
+ * lives here instead of being re-remembered at each call site.
41
+ */
42
+ paymentMethod?: string;
43
+ /** The admin checkout bypass (`siteSettings.featureFlags.adminCheckoutBypass`). */
44
+ adminBypass?: boolean;
45
+ }
46
+ /**
47
+ * Throws **403 `CHECKOUT_VALUE_OTP_REQUIRED`** when the amount clears the
48
+ * threshold and the buyer has not verified. Returns silently otherwise.
49
+ *
50
+ * 🛑 Call it BEFORE creating a gateway order, never after. Verifying
51
+ * post-payment means charging the buyer and then asking whether they were
52
+ * allowed to be charged — which is what the comment at the gateway call site
53
+ * has always said and is the reason that copy sits where it does.
54
+ */
55
+ export declare function assertCheckoutValueOtp(input: CheckoutValueOtpGateInput): Promise<void>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The high-value OTP gate — one implementation, taking the amount it judges.
3
+ *
4
+ * ## Why this exists as a function rather than two `if`s
5
+ *
6
+ * There were two hand-rolled copies: one in `createOrderForGroup`'s lane
7
+ * block and one in `POST /api/payment/create-order`. They already disagreed
8
+ * on what they measured — the first summed `price × quantity` over the cart,
9
+ * the second used the gateway route's own `subtotalRs` — and neither could
10
+ * be reused by anything else, because the amount was computed inline rather
11
+ * than passed in. That is Root Cause #75's shape exactly: a rule reachable
12
+ * only by copy-paste gets copied, and a copy made before a branch was added
13
+ * never learns about it.
14
+ *
15
+ * ## 🛑 It takes an AMOUNT, and that is the Part 6 prerequisite
16
+ *
17
+ * `otpCheckoutThreshold` is per CHECKOUT, so three ₹4,000 checkouts clear
18
+ * ₹12,000 with no OTP — and splitting an order is precisely what a shared
19
+ * purchase does. The gate therefore cannot keep deriving its own figure from
20
+ * whatever cart happens to be in scope; the caller has to say what sum is
21
+ * being authorised. For an ordinary checkout that is the cart; for an
22
+ * N-party arrangement it is the ARRANGEMENT total, charged across several
23
+ * payments that are individually under the line.
24
+ *
25
+ * Passing the amount in is the whole change. There is no arrangement yet to
26
+ * pass one, which is why this lands as a prerequisite rather than as a
27
+ * feature — but the gate can no longer be dodged by a caller that did not
28
+ * exist when the rule was written.
29
+ */
30
+ import { ApiError } from "../../../../errors/api-error.js";
31
+ import { isCheckoutValueOtpVerified } from "../../../../features/checkout/actions/checkout-value-otp-actions.js";
32
+ /**
33
+ * Throws **403 `CHECKOUT_VALUE_OTP_REQUIRED`** when the amount clears the
34
+ * threshold and the buyer has not verified. Returns silently otherwise.
35
+ *
36
+ * 🛑 Call it BEFORE creating a gateway order, never after. Verifying
37
+ * post-payment means charging the buyer and then asking whether they were
38
+ * allowed to be charged — which is what the comment at the gateway call site
39
+ * has always said and is the reason that copy sits where it does.
40
+ */
41
+ export async function assertCheckoutValueOtp(input) {
42
+ const { uid, amount, threshold, paymentMethod, adminBypass } = input;
43
+ if (adminBypass)
44
+ return;
45
+ if (paymentMethod === "cod")
46
+ return;
47
+ if (typeof threshold !== "number" || threshold <= 0)
48
+ return;
49
+ /*
50
+ * `>=`, not `>`. A threshold of ₹5,000 means a ₹5,000 checkout is gated —
51
+ * the schema comment on `otpCheckoutThreshold` calls it a MINIMUM, and
52
+ * both previous copies already read it that way.
53
+ */
54
+ if (amount < threshold)
55
+ return;
56
+ const verified = await isCheckoutValueOtpVerified(uid);
57
+ if (!verified) {
58
+ throw new ApiError(403, "CHECKOUT_VALUE_OTP_REQUIRED");
59
+ }
60
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Attention counts for the dashboard hubs — "how many things here are waiting
3
+ * for me".
4
+ *
5
+ * ## 🛑 A count that fails renders ABSENT, never `0`
6
+ *
7
+ * This is the one rule the whole module is shaped around, and it is the
8
+ * opposite of what `dailyStatusDigest`'s `safeCount` does. The digest returns
9
+ * `0` on a failed query and says so: *"a stuck zero next to live numbers
10
+ * elsewhere is legible as 'that one broke'"*. That is true of a table of
11
+ * twenty figures in an email and false of a badge on a card — there, `0` reads
12
+ * as **"nothing needs attention"**, which is the one wrong answer that stops
13
+ * the operator looking.
14
+ *
15
+ * So a failed source resolves to `null` and the card renders no badge at all.
16
+ * Root Cause #72: `undefined` must mean "the count failed", never "zero", and
17
+ * every count fails OPEN.
18
+ *
19
+ * ## Keyed by nav item id, not by section
20
+ *
21
+ * A count belongs to a SCREEN ("2 payouts are pending"), not to a section, so
22
+ * the key is `navItemId(portal, href)` — already derived, already stable,
23
+ * already the `navConfig` key. A section card sums its own items' counts, so
24
+ * no second keying scheme exists and a renamed group cannot orphan a count.
25
+ *
26
+ * 🛑 The ids are written as literals here and CROSS-CHECKED against the real
27
+ * nav by `audit-dashboard-hub-coverage`. A literal that matches no nav item is
28
+ * a count nothing can ever display — the silent half of Root Cause #62, where
29
+ * a filter is emitted on a field the allowlist omits and simply vanishes.
30
+ *
31
+ * ## 🛑 `nav-admin-admin-orders` is CORRECT. Do not "tidy" the repetition.
32
+ *
33
+ * `navItemId(portal, href)` is `nav-${portal}-${slugified href}`, and the href
34
+ * ALSO begins with the portal segment — so `/admin/orders` under the `admin`
35
+ * portal is `nav-admin-admin-orders`, and `/store/orders` is
36
+ * `nav-store-store-orders`. It reads like a mistake and is the live `navConfig`
37
+ * key.
38
+ *
39
+ * Every key in this registry was first written the way it reads — a single
40
+ * `nav-admin-orders` — and the consequence is worth recording because it is
41
+ * precisely the failure mode this registry's cross-check exists for:
42
+ * `collectAttentionCounts` looks each VISIBLE nav id up in here, so with the
43
+ * shortened keys **not one source would ever have matched**. No query would
44
+ * run, no badge would appear, and nothing anywhere would error — a feature
45
+ * that silently does nothing, which is indistinguishable from "nothing is
46
+ * waiting". `audit-dashboard-hub-coverage`'s R3 caught all six on its first
47
+ * clean run.
48
+ *
49
+ * ## Why these counts and not more
50
+ *
51
+ * Every source below is a query COPIED from one that already runs in
52
+ * production — four from `dailyStatusDigest`, which documents its own index
53
+ * story, plus the seller order count. None of them needed a new index:
54
+ * all are single-field equalities except the manual-payment pair (which reuses
55
+ * the existing `(status, paymentStatus, createdAt)` composite) and the seller
56
+ * pair (which is a prefix of `(storeId, status, createdAt)`). Verified against
57
+ * `appkit/firebase/base/firestore.indexes.json` rather than assumed.
58
+ *
59
+ * Inventing a count means choosing what "needs attention" means for a
60
+ * collection, and a wrong choice here is a badge that is confidently wrong
61
+ * forever. Adding one is a registry entry plus that decision, made once.
62
+ *
63
+ * **The user portal has no counts, deliberately.** Nothing on a buyer's
64
+ * dashboard is an obligation they are failing to meet — their unread count
65
+ * already lives in the notification bell, and duplicating it onto a card would
66
+ * be a second place for the same number to be stale. The registry simply has
67
+ * no `nav-user-*` key, so the user hubs run zero queries rather than running
68
+ * queries that return a number nobody acts on.
69
+ */
70
+ import { getAdminDb } from "../../../../providers/db-firebase";
71
+ /**
72
+ * `null` is a FAILED read; a missing key is "no count is defined for this
73
+ * screen". Both render as no badge, and the distinction matters only here.
74
+ */
75
+ export type AttentionCounts = Record<string, number | null>;
76
+ type CountableQuery = {
77
+ count(): {
78
+ get(): Promise<{
79
+ data(): {
80
+ count: number;
81
+ };
82
+ }>;
83
+ };
84
+ };
85
+ /**
86
+ * What each count needs from the viewer. A source whose requirement cannot be
87
+ * met is not run — it is not reported as `0` either, it is simply absent.
88
+ */
89
+ interface AttentionSource {
90
+ /**
91
+ * Build the query. `storeId` is the viewer's store SLUG when they have one.
92
+ *
93
+ * 🛑 A store SLUG, not an Auth uid — `OrderDocument.storeId` holds the slug
94
+ * (§ "Store Identity Architecture"). Passing a uid returns zero for every
95
+ * seller and raises nothing, which is Root Cause #98's second bug.
96
+ */
97
+ query: (db: ReturnType<typeof getAdminDb>, storeId?: string) => CountableQuery | null;
98
+ }
99
+ /**
100
+ * nav item id → how to count what is waiting there.
101
+ *
102
+ * 🛑 Keys are cross-checked against the real nav by
103
+ * `audit-dashboard-hub-coverage`. A key matching no nav item is a count that
104
+ * can never be displayed — the silent half of Root Cause #62.
105
+ */
106
+ export declare const ATTENTION_SOURCES: Record<string, AttentionSource>;
107
+ /**
108
+ * Count what is waiting, for the nav items this viewer can actually see.
109
+ *
110
+ * ## 🛑 `visibleItemIds` is the gate, and it is not an optimisation
111
+ *
112
+ * The caller passes the ids that SURVIVED permission filtering, so a count is
113
+ * only ever computed for a card that will be rendered. Two things fall out of
114
+ * that which a blanket "am I staff" boolean could not give:
115
+ *
116
+ * - An employee holding one permission pays for one count, not six. The first
117
+ * draft derived staffness from `permissions.length > 0` and ran all five
118
+ * admin queries for anyone with any grant at all — six reads to render one
119
+ * card, and a number computed from a collection that employee has no
120
+ * permission to read.
121
+ * - A seller and an admin need no separate branches. The store nav contains
122
+ * `nav-store-orders` and the admin nav does not, so the nav IS the scope.
123
+ *
124
+ * `Promise.all` over `.count()` aggregations: six reads worst case, none
125
+ * scaling with collection size, parallel rather than sequential (Rule #6).
126
+ */
127
+ export declare function collectAttentionCounts(visibleItemIds: readonly string[], storeId?: string): Promise<AttentionCounts>;
128
+ export {};
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Attention counts for the dashboard hubs — "how many things here are waiting
3
+ * for me".
4
+ *
5
+ * ## 🛑 A count that fails renders ABSENT, never `0`
6
+ *
7
+ * This is the one rule the whole module is shaped around, and it is the
8
+ * opposite of what `dailyStatusDigest`'s `safeCount` does. The digest returns
9
+ * `0` on a failed query and says so: *"a stuck zero next to live numbers
10
+ * elsewhere is legible as 'that one broke'"*. That is true of a table of
11
+ * twenty figures in an email and false of a badge on a card — there, `0` reads
12
+ * as **"nothing needs attention"**, which is the one wrong answer that stops
13
+ * the operator looking.
14
+ *
15
+ * So a failed source resolves to `null` and the card renders no badge at all.
16
+ * Root Cause #72: `undefined` must mean "the count failed", never "zero", and
17
+ * every count fails OPEN.
18
+ *
19
+ * ## Keyed by nav item id, not by section
20
+ *
21
+ * A count belongs to a SCREEN ("2 payouts are pending"), not to a section, so
22
+ * the key is `navItemId(portal, href)` — already derived, already stable,
23
+ * already the `navConfig` key. A section card sums its own items' counts, so
24
+ * no second keying scheme exists and a renamed group cannot orphan a count.
25
+ *
26
+ * 🛑 The ids are written as literals here and CROSS-CHECKED against the real
27
+ * nav by `audit-dashboard-hub-coverage`. A literal that matches no nav item is
28
+ * a count nothing can ever display — the silent half of Root Cause #62, where
29
+ * a filter is emitted on a field the allowlist omits and simply vanishes.
30
+ *
31
+ * ## 🛑 `nav-admin-admin-orders` is CORRECT. Do not "tidy" the repetition.
32
+ *
33
+ * `navItemId(portal, href)` is `nav-${portal}-${slugified href}`, and the href
34
+ * ALSO begins with the portal segment — so `/admin/orders` under the `admin`
35
+ * portal is `nav-admin-admin-orders`, and `/store/orders` is
36
+ * `nav-store-store-orders`. It reads like a mistake and is the live `navConfig`
37
+ * key.
38
+ *
39
+ * Every key in this registry was first written the way it reads — a single
40
+ * `nav-admin-orders` — and the consequence is worth recording because it is
41
+ * precisely the failure mode this registry's cross-check exists for:
42
+ * `collectAttentionCounts` looks each VISIBLE nav id up in here, so with the
43
+ * shortened keys **not one source would ever have matched**. No query would
44
+ * run, no badge would appear, and nothing anywhere would error — a feature
45
+ * that silently does nothing, which is indistinguishable from "nothing is
46
+ * waiting". `audit-dashboard-hub-coverage`'s R3 caught all six on its first
47
+ * clean run.
48
+ *
49
+ * ## Why these counts and not more
50
+ *
51
+ * Every source below is a query COPIED from one that already runs in
52
+ * production — four from `dailyStatusDigest`, which documents its own index
53
+ * story, plus the seller order count. None of them needed a new index:
54
+ * all are single-field equalities except the manual-payment pair (which reuses
55
+ * the existing `(status, paymentStatus, createdAt)` composite) and the seller
56
+ * pair (which is a prefix of `(storeId, status, createdAt)`). Verified against
57
+ * `appkit/firebase/base/firestore.indexes.json` rather than assumed.
58
+ *
59
+ * Inventing a count means choosing what "needs attention" means for a
60
+ * collection, and a wrong choice here is a badge that is confidently wrong
61
+ * forever. Adding one is a registry entry plus that decision, made once.
62
+ *
63
+ * **The user portal has no counts, deliberately.** Nothing on a buyer's
64
+ * dashboard is an obligation they are failing to meet — their unread count
65
+ * already lives in the notification bell, and duplicating it onto a card would
66
+ * be a second place for the same number to be stale. The registry simply has
67
+ * no `nav-user-*` key, so the user hubs run zero queries rather than running
68
+ * queries that return a number nobody acts on.
69
+ */
70
+ import { getAdminDb } from "../../../../providers/db-firebase/index.js";
71
+ import { normalizeError } from "../../../../errors/normalize.js";
72
+ import { ORDER_COLLECTION, OrderStatusValues } from "../../../../features/orders/schemas/firestore.js";
73
+ import { NOTIFICATION_FIELDS, ORDER_FIELDS } from "../../../../constants/field-names.js";
74
+ import { PAYOUT_COLLECTION, PAYOUT_FIELDS } from "../../../../features/payments/schemas/firestore.js";
75
+ import { SUPPORT_TICKET_COLLECTION, SUPPORT_TICKET_FIELDS, TicketStatusValues, } from "../../../../features/support/schemas/firestore.js";
76
+ import { ADMIN_NOTIFICATIONS_COLLECTION } from "../../../../features/store-extensions/schemas/rbac.js";
77
+ import { CATALOGUE_COLLECTION, CATALOGUE_FIELDS, } from "../../../../features/catalogue/schemas/firestore.js";
78
+ /** `null` on any failure — see the header. */
79
+ async function countOrNull(q) {
80
+ try {
81
+ const snap = await q.count().get();
82
+ return snap.data().count;
83
+ }
84
+ catch (err) {
85
+ void normalizeError(err);
86
+ return null;
87
+ }
88
+ }
89
+ /**
90
+ * nav item id → how to count what is waiting there.
91
+ *
92
+ * 🛑 Keys are cross-checked against the real nav by
93
+ * `audit-dashboard-hub-coverage`. A key matching no nav item is a count that
94
+ * can never be displayed — the silent half of Root Cause #62.
95
+ */
96
+ export const ATTENTION_SOURCES = {
97
+ /*
98
+ * Orders awaiting a manual-payment decision. The digest's exact query —
99
+ * `status == pending AND paymentStatus == pending` — which is what
100
+ * § "Manual Payment Review Flow" calls the review queue's bounded
101
+ * pre-filter, and which reuses the existing
102
+ * `(status, paymentStatus, createdAt)` composite.
103
+ */
104
+ "nav-admin-admin-orders": {
105
+ query: (db) => db
106
+ .collection(ORDER_COLLECTION)
107
+ .where(ORDER_FIELDS.STATUS, "==", OrderStatusValues.PENDING)
108
+ .where(ORDER_FIELDS.PAYMENT_STATUS, "==", "pending"),
109
+ },
110
+ "nav-admin-admin-payouts": {
111
+ query: (db) => db
112
+ .collection(PAYOUT_COLLECTION)
113
+ .where(PAYOUT_FIELDS.STATUS, "==", PAYOUT_FIELDS.STATUS_VALUES.PENDING),
114
+ },
115
+ "nav-admin-admin-support-tickets": {
116
+ query: (db) => db
117
+ .collection(SUPPORT_TICKET_COLLECTION)
118
+ .where(SUPPORT_TICKET_FIELDS.STATUS, "==", TicketStatusValues.OPEN),
119
+ },
120
+ "nav-admin-admin-notifications": {
121
+ /*
122
+ * 🛑 `NOTIFICATION_FIELDS.IS_READ`, not the literal `"isRead"`.
123
+ * `dailyStatusDigest` writes the bare string with an
124
+ * `audit-field-name-ok` marker and a long justification — that `isRead` is
125
+ * a `BaseDocument` field rather than a per-feature name — but the marker
126
+ * is not needed here because a real constant for it exists, and a
127
+ * constant that exists is always better than a waiver for not using it.
128
+ */
129
+ query: (db) => db.collection(ADMIN_NOTIFICATIONS_COLLECTION).where(NOTIFICATION_FIELDS.IS_READ, "==", false),
130
+ },
131
+ "nav-admin-admin-catalogue-approvals": {
132
+ query: (db) => db
133
+ .collection(CATALOGUE_COLLECTION)
134
+ .where(CATALOGUE_FIELDS.LISTING_STATUS, "==", "pending_admin_approval"),
135
+ },
136
+ /* A prefix of the existing `(storeId, status, createdAt)` composite. */
137
+ "nav-store-store-orders": {
138
+ query: (db, storeId) => storeId
139
+ ? db
140
+ .collection(ORDER_COLLECTION)
141
+ .where(ORDER_FIELDS.STORE_ID, "==", storeId)
142
+ .where(ORDER_FIELDS.STATUS, "==", OrderStatusValues.PENDING)
143
+ : null,
144
+ },
145
+ };
146
+ /**
147
+ * Count what is waiting, for the nav items this viewer can actually see.
148
+ *
149
+ * ## 🛑 `visibleItemIds` is the gate, and it is not an optimisation
150
+ *
151
+ * The caller passes the ids that SURVIVED permission filtering, so a count is
152
+ * only ever computed for a card that will be rendered. Two things fall out of
153
+ * that which a blanket "am I staff" boolean could not give:
154
+ *
155
+ * - An employee holding one permission pays for one count, not six. The first
156
+ * draft derived staffness from `permissions.length > 0` and ran all five
157
+ * admin queries for anyone with any grant at all — six reads to render one
158
+ * card, and a number computed from a collection that employee has no
159
+ * permission to read.
160
+ * - A seller and an admin need no separate branches. The store nav contains
161
+ * `nav-store-orders` and the admin nav does not, so the nav IS the scope.
162
+ *
163
+ * `Promise.all` over `.count()` aggregations: six reads worst case, none
164
+ * scaling with collection size, parallel rather than sequential (Rule #6).
165
+ */
166
+ export async function collectAttentionCounts(visibleItemIds, storeId) {
167
+ const db = getAdminDb();
168
+ const runnable = [];
169
+ for (const id of visibleItemIds) {
170
+ const source = ATTENTION_SOURCES[id];
171
+ if (!source)
172
+ continue;
173
+ const q = source.query(db, storeId);
174
+ if (q)
175
+ runnable.push([id, q]);
176
+ }
177
+ const results = await Promise.all(runnable.map(([, q]) => countOrNull(q)));
178
+ const counts = {};
179
+ runnable.forEach(([key], i) => {
180
+ counts[key] = results[i] ?? null;
181
+ });
182
+ return counts;
183
+ }