@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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * D56 — a SELF-SERVE ad booking: one advertiser, one placement, one period.
3
+ *
4
+ * ## 🛑 Flat fee per slot per period, and explicitly NOT CPM
5
+ *
6
+ * Impression counting is a write per page view, which is the exact cost
7
+ * pattern that suspended this project (Root Cause #104: 3.1M ISR writes
8
+ * against a 200K allowance, 52.52 GB of Fast Origin Transfer against 10 GB,
9
+ * for eleven monthly active users). A flat fee is one write per BOOKING.
10
+ * Nothing here counts anything per render, and nothing should be added that
11
+ * does.
12
+ *
13
+ * ## Why a PERIOD rather than an arbitrary date range
14
+ *
15
+ * The plan's requirement is slot-availability locking — two advertisers must
16
+ * not book the same slot for overlapping dates. Firestore has no range
17
+ * constraint, so an arbitrary `startAt`/`endAt` pair can only be checked by
18
+ * QUERYING for overlaps and then writing, and two concurrent bookings both
19
+ * read "no overlap" and both write. That race is invisible in testing and
20
+ * sells the same slot twice.
21
+ *
22
+ * Discretising to a period makes the conflict a DOCUMENT COLLISION instead:
23
+ * the booking id is derived from `(placementId, period)`, so the second
24
+ * writer fails on an existing document inside its own transaction and
25
+ * Firestore does the locking. There is no query to race against.
26
+ *
27
+ * It also matches what is being sold. "The homepage banner for November" is
28
+ * the product; "the homepage banner from the 9th to the 23rd" is a pricing
29
+ * model nobody asked for and an availability calendar nobody can render.
30
+ */
31
+ import type { BaseDocument } from "../../../_internal/shared/types/base-document";
32
+ export declare const AD_BOOKING_COLLECTION = "adBookings";
33
+ /**
34
+ * A calendar month, `YYYY-MM`.
35
+ *
36
+ * 🛑 A STRING, not a Date. It is an identity — half of the booking's primary
37
+ * key — and a Date would make "the same month" a comparison rather than an
38
+ * equality, which is precisely what the document-collision lock depends on
39
+ * not being.
40
+ */
41
+ export type AdPeriod = string;
42
+ export declare function isAdPeriod(value: string): value is AdPeriod;
43
+ /** The period a date falls in, in UTC. */
44
+ export declare function adPeriodOf(date: Date): AdPeriod;
45
+ /** First instant of a period, UTC. */
46
+ export declare function adPeriodStart(period: AdPeriod): Date;
47
+ /** First instant of the NEXT period — the exclusive end. */
48
+ export declare function adPeriodEnd(period: AdPeriod): Date;
49
+ /**
50
+ * 🛑 The booking id IS the lock. Derived, never generated.
51
+ *
52
+ * Two advertisers booking `banner-home` for `2026-11` compute the same id and
53
+ * the second `tx.create` throws `ALREADY_EXISTS` — which is the whole
54
+ * availability guarantee, enforced by Firestore rather than by a check
55
+ * somebody could forget to run.
56
+ */
57
+ export declare function adBookingId(placementId: string, period: AdPeriod): string;
58
+ /**
59
+ * Payment state. Deliberately coarse: this records whether the slot is PAID
60
+ * FOR, not how. The invoice and the ledger posting are Part 4's job and carry
61
+ * the detail; duplicating any of it here would be two records of one fact.
62
+ */
63
+ export declare const AD_BOOKING_PAYMENT_STATES: readonly ["unpaid", "paid", "refunded"];
64
+ export type AdBookingPaymentState = (typeof AD_BOOKING_PAYMENT_STATES)[number];
65
+ /**
66
+ * Creative moderation. **Nothing renders on the site until `approved`.**
67
+ *
68
+ * 🛑 `pending` is the initial value and there is no way to write `approved`
69
+ * through the advertiser's own schema — see `adBookingWriteSchema`. A
70
+ * self-serve flow where the submitter can set their own moderation state is
71
+ * not moderated; it is a form with an extra field (Root Cause #76 — a field
72
+ * the form can send is a field the form can destroy, here inverted into a
73
+ * field the form can grant itself).
74
+ */
75
+ export declare const AD_MODERATION_STATES: readonly ["pending", "approved", "rejected"];
76
+ export type AdModerationState = (typeof AD_MODERATION_STATES)[number];
77
+ export interface AdBookingCreative {
78
+ title: string;
79
+ body?: string;
80
+ imageUrl?: string;
81
+ ctaLabel?: string;
82
+ ctaHref?: string;
83
+ }
84
+ export interface AdBookingDocument extends BaseDocument {
85
+ /** Matches a `SiteSettingsAdPlacement.id`. */
86
+ placementId: string;
87
+ period: AdPeriod;
88
+ /** Auth uid of the advertiser. */
89
+ advertiserUid: string;
90
+ /** Denormalised for the admin queue — a moderator should not fan out to `users`. */
91
+ advertiserName?: string;
92
+ /**
93
+ * Rupees, SNAPSHOTTED at booking.
94
+ *
95
+ * 🛑 Never recomputed from the live rate card. The rate is a price the
96
+ * advertiser agreed to; re-deriving it at render or invoice time would
97
+ * silently reprice a sold slot the first time an admin edits the card —
98
+ * the same rule `sourceContext` follows for order provenance.
99
+ */
100
+ priceRs: number;
101
+ paymentState: AdBookingPaymentState;
102
+ /** Set by the Part 4 invoicing path once the booking is billed. */
103
+ invoiceId?: string;
104
+ creative: AdBookingCreative;
105
+ moderation: {
106
+ state: AdModerationState;
107
+ /** Required on a rejection — see `moderateAdCreative`. */
108
+ note?: string;
109
+ reviewedAt?: Date;
110
+ /**
111
+ * Staff uid. 🛑 Never returned to the advertiser: who rejected you is
112
+ * not information a self-serve submitter needs, and naming an individual
113
+ * moderator invites them being contacted directly.
114
+ */
115
+ reviewedBy?: string;
116
+ };
117
+ /** Set when an admin or the advertiser cancels. A cancelled booking frees nothing — see below. */
118
+ cancelledAt?: Date;
119
+ cancelledReason?: string;
120
+ }
121
+ /**
122
+ * Does this booking put a creative on the page right now?
123
+ *
124
+ * 🛑 All four conditions, and the order is not an accident — moderation is
125
+ * checked before the calendar so an approved-but-future booking and an
126
+ * unreviewed current one are distinguishable in a debugger.
127
+ */
128
+ export declare function isAdBookingLive(booking: Pick<AdBookingDocument, "moderation" | "paymentState" | "period" | "cancelledAt">, now?: Date): boolean;
129
+ /**
130
+ * 🛑 A cancelled booking KEEPS its document, and therefore keeps the slot
131
+ * locked for its own period.
132
+ *
133
+ * Deleting it would free the slot — which sounds like the point and is the
134
+ * wrong behaviour twice over: the advertiser has paid for a period that has
135
+ * possibly already started, and re-selling a month mid-flight means two
136
+ * creatives were live in one slot at different times with one invoice
137
+ * between them. Freeing a period is an ADMIN act (`releaseAdSlot`), done
138
+ * knowingly, not a side effect of a cancel button.
139
+ */
140
+ export declare const AD_BOOKING_CANCELLED_KEEPS_SLOT = true;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * D56 — a SELF-SERVE ad booking: one advertiser, one placement, one period.
3
+ *
4
+ * ## 🛑 Flat fee per slot per period, and explicitly NOT CPM
5
+ *
6
+ * Impression counting is a write per page view, which is the exact cost
7
+ * pattern that suspended this project (Root Cause #104: 3.1M ISR writes
8
+ * against a 200K allowance, 52.52 GB of Fast Origin Transfer against 10 GB,
9
+ * for eleven monthly active users). A flat fee is one write per BOOKING.
10
+ * Nothing here counts anything per render, and nothing should be added that
11
+ * does.
12
+ *
13
+ * ## Why a PERIOD rather than an arbitrary date range
14
+ *
15
+ * The plan's requirement is slot-availability locking — two advertisers must
16
+ * not book the same slot for overlapping dates. Firestore has no range
17
+ * constraint, so an arbitrary `startAt`/`endAt` pair can only be checked by
18
+ * QUERYING for overlaps and then writing, and two concurrent bookings both
19
+ * read "no overlap" and both write. That race is invisible in testing and
20
+ * sells the same slot twice.
21
+ *
22
+ * Discretising to a period makes the conflict a DOCUMENT COLLISION instead:
23
+ * the booking id is derived from `(placementId, period)`, so the second
24
+ * writer fails on an existing document inside its own transaction and
25
+ * Firestore does the locking. There is no query to race against.
26
+ *
27
+ * It also matches what is being sold. "The homepage banner for November" is
28
+ * the product; "the homepage banner from the 9th to the 23rd" is a pricing
29
+ * model nobody asked for and an availability calendar nobody can render.
30
+ */
31
+ export const AD_BOOKING_COLLECTION = "adBookings";
32
+ const PERIOD_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
33
+ export function isAdPeriod(value) {
34
+ return PERIOD_PATTERN.test(value);
35
+ }
36
+ /** The period a date falls in, in UTC. */
37
+ export function adPeriodOf(date) {
38
+ const y = date.getUTCFullYear();
39
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
40
+ return `${y}-${m}`;
41
+ }
42
+ /** First instant of a period, UTC. */
43
+ export function adPeriodStart(period) {
44
+ const [y, m] = period.split("-").map(Number);
45
+ return new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
46
+ }
47
+ /** First instant of the NEXT period — the exclusive end. */
48
+ export function adPeriodEnd(period) {
49
+ const [y, m] = period.split("-").map(Number);
50
+ return new Date(Date.UTC(y, m, 1, 0, 0, 0, 0));
51
+ }
52
+ /**
53
+ * 🛑 The booking id IS the lock. Derived, never generated.
54
+ *
55
+ * Two advertisers booking `banner-home` for `2026-11` compute the same id and
56
+ * the second `tx.create` throws `ALREADY_EXISTS` — which is the whole
57
+ * availability guarantee, enforced by Firestore rather than by a check
58
+ * somebody could forget to run.
59
+ */
60
+ export function adBookingId(placementId, period) {
61
+ return `${placementId}__${period}`;
62
+ }
63
+ /**
64
+ * Payment state. Deliberately coarse: this records whether the slot is PAID
65
+ * FOR, not how. The invoice and the ledger posting are Part 4's job and carry
66
+ * the detail; duplicating any of it here would be two records of one fact.
67
+ */
68
+ export const AD_BOOKING_PAYMENT_STATES = ["unpaid", "paid", "refunded"];
69
+ /**
70
+ * Creative moderation. **Nothing renders on the site until `approved`.**
71
+ *
72
+ * 🛑 `pending` is the initial value and there is no way to write `approved`
73
+ * through the advertiser's own schema — see `adBookingWriteSchema`. A
74
+ * self-serve flow where the submitter can set their own moderation state is
75
+ * not moderated; it is a form with an extra field (Root Cause #76 — a field
76
+ * the form can send is a field the form can destroy, here inverted into a
77
+ * field the form can grant itself).
78
+ */
79
+ export const AD_MODERATION_STATES = ["pending", "approved", "rejected"];
80
+ /**
81
+ * Does this booking put a creative on the page right now?
82
+ *
83
+ * 🛑 All four conditions, and the order is not an accident — moderation is
84
+ * checked before the calendar so an approved-but-future booking and an
85
+ * unreviewed current one are distinguishable in a debugger.
86
+ */
87
+ export function isAdBookingLive(booking, now = new Date()) {
88
+ if (booking.cancelledAt)
89
+ return false;
90
+ if (booking.moderation.state !== "approved")
91
+ return false;
92
+ if (booking.paymentState !== "paid")
93
+ return false;
94
+ const start = adPeriodStart(booking.period);
95
+ const end = adPeriodEnd(booking.period);
96
+ return now >= start && now < end;
97
+ }
98
+ /**
99
+ * 🛑 A cancelled booking KEEPS its document, and therefore keeps the slot
100
+ * locked for its own period.
101
+ *
102
+ * Deleting it would free the slot — which sounds like the point and is the
103
+ * wrong behaviour twice over: the advertiser has paid for a period that has
104
+ * possibly already started, and re-selling a month mid-flight means two
105
+ * creatives were live in one slot at different times with one invoice
106
+ * between them. Freeing a period is an ADMIN act (`releaseAdSlot`), done
107
+ * knowingly, not a side effect of a cancel button.
108
+ */
109
+ export const AD_BOOKING_CANCELLED_KEEPS_SLOT = true;
@@ -276,7 +276,9 @@ export class BidRepository extends BaseRepository {
276
276
  reason: ctx?.reason,
277
277
  note: ctx?.note,
278
278
  // `userName`/`userEmail` are denormalised onto every bid, and
279
- // `encryptPiiFields` never descends into arrays.
279
+ // `encryptPiiFields` covers a field only where it is NAMED. It CAN reach
280
+ // into an array via `array[].key`, but a bare key does not — so an
281
+ // undeclared nested value is stored in plaintext.
280
282
  piiFields: BID_HISTORY_PII_FIELDS,
281
283
  });
282
284
  return withEntry ?? patch;
@@ -59,6 +59,61 @@ export declare const AdminAuditActionValues: {
59
59
  * returns the existing row, which is not an act worth logging.
60
60
  */
61
61
  readonly DISPUTE_REASON_CREATE: "dispute_reason_create";
62
+ readonly DISPUTE_REASON_RETIRE: "dispute_reason_retire";
63
+ /**
64
+ * An admin changed a platform rate through `/admin/pricing`.
65
+ *
66
+ * 🛑 Recorded here as well as in `siteSettings.rateChanges`, and the two are
67
+ * not redundant. The rate log answers "when did THIS rate last move" and is
68
+ * capped, so it can lose its oldest entries; this answers "what have admins
69
+ * done lately" across every privileged act, and is the one that has to
70
+ * survive. Same split as `points_adjust` against its ledger.
71
+ *
72
+ * Every rate here is money the platform charges or withholds, so this is the
73
+ * same class as `coupon_update` and `payout_mark_paid`.
74
+ */
75
+ readonly PRICING_UPDATE: "pricing_update";
76
+ /**
77
+ * An admin granted or revoked `trustedSeller` on a store.
78
+ *
79
+ * 🛑 Its OWN action rather than folded into `store_status_change`, because
80
+ * "what changed about this store" and "who accepted this risk" are
81
+ * different questions. Trusting a seller means paying before delivery, so
82
+ * the platform carries a lost parcel and a non-delivery dispute — and when
83
+ * one of those lands, the question is who decided and when.
84
+ *
85
+ * Recorded on a real change only. Re-saving an unrelated field on an
86
+ * already-trusted store writes nothing; a log of non-events stops being
87
+ * read.
88
+ */
89
+ readonly STORE_TRUST_CHANGE: "store_trust_change";
90
+ /** A role's permission set changed — the definition of who can do what. */
91
+ readonly ROLE_CHANGE: "role_change";
92
+ /**
93
+ * A tax code's rate or HSN mapping changed.
94
+ *
95
+ * 🛑 The one STATUTORY thing an admin can edit, which is why
96
+ * `/admin/pricing` deliberately does not mirror it. A wrong rate here is a
97
+ * wrong return, so "who set this and when" is the question an assessment
98
+ * asks first.
99
+ */
100
+ readonly TAX_CODE_CHANGE: "tax_code_change";
101
+ /** Sessions forcibly revoked — the user is signed out everywhere, now. */
102
+ readonly SESSION_REVOKE: "session_revoke";
103
+ /** A bulk operation over many users at once. */
104
+ readonly USER_BULK_ACTION: "user_bulk_action";
105
+ /** A moderation report resolved, dismissed or actioned. */
106
+ readonly MODERATION_DECISION: "moderation_decision";
107
+ /** A payment case claimed or resolved — money a buyer says did not arrive. */
108
+ readonly PAYMENT_CASE_DECISION: "payment_case_decision";
109
+ /**
110
+ * A scammer profile edited.
111
+ *
112
+ * 🛑 Public-facing accusation. The profile names a person or a payment
113
+ * handle, so the wording and the status are reputational — and the one
114
+ * question that follows a complaint is who wrote it.
115
+ */
116
+ readonly SCAMMER_PROFILE_UPDATE: "scammer_profile_update";
62
117
  };
63
118
  export type AdminAuditAction = (typeof AdminAuditActionValues)[keyof typeof AdminAuditActionValues];
64
119
  export interface AdminAuditLogDocument extends BaseDocument {
@@ -127,5 +182,60 @@ export declare const ADMIN_AUDIT_LOG_FIELDS: {
127
182
  * returns the existing row, which is not an act worth logging.
128
183
  */
129
184
  readonly DISPUTE_REASON_CREATE: "dispute_reason_create";
185
+ readonly DISPUTE_REASON_RETIRE: "dispute_reason_retire";
186
+ /**
187
+ * An admin changed a platform rate through `/admin/pricing`.
188
+ *
189
+ * 🛑 Recorded here as well as in `siteSettings.rateChanges`, and the two are
190
+ * not redundant. The rate log answers "when did THIS rate last move" and is
191
+ * capped, so it can lose its oldest entries; this answers "what have admins
192
+ * done lately" across every privileged act, and is the one that has to
193
+ * survive. Same split as `points_adjust` against its ledger.
194
+ *
195
+ * Every rate here is money the platform charges or withholds, so this is the
196
+ * same class as `coupon_update` and `payout_mark_paid`.
197
+ */
198
+ readonly PRICING_UPDATE: "pricing_update";
199
+ /**
200
+ * An admin granted or revoked `trustedSeller` on a store.
201
+ *
202
+ * 🛑 Its OWN action rather than folded into `store_status_change`, because
203
+ * "what changed about this store" and "who accepted this risk" are
204
+ * different questions. Trusting a seller means paying before delivery, so
205
+ * the platform carries a lost parcel and a non-delivery dispute — and when
206
+ * one of those lands, the question is who decided and when.
207
+ *
208
+ * Recorded on a real change only. Re-saving an unrelated field on an
209
+ * already-trusted store writes nothing; a log of non-events stops being
210
+ * read.
211
+ */
212
+ readonly STORE_TRUST_CHANGE: "store_trust_change";
213
+ /** A role's permission set changed — the definition of who can do what. */
214
+ readonly ROLE_CHANGE: "role_change";
215
+ /**
216
+ * A tax code's rate or HSN mapping changed.
217
+ *
218
+ * 🛑 The one STATUTORY thing an admin can edit, which is why
219
+ * `/admin/pricing` deliberately does not mirror it. A wrong rate here is a
220
+ * wrong return, so "who set this and when" is the question an assessment
221
+ * asks first.
222
+ */
223
+ readonly TAX_CODE_CHANGE: "tax_code_change";
224
+ /** Sessions forcibly revoked — the user is signed out everywhere, now. */
225
+ readonly SESSION_REVOKE: "session_revoke";
226
+ /** A bulk operation over many users at once. */
227
+ readonly USER_BULK_ACTION: "user_bulk_action";
228
+ /** A moderation report resolved, dismissed or actioned. */
229
+ readonly MODERATION_DECISION: "moderation_decision";
230
+ /** A payment case claimed or resolved — money a buyer says did not arrive. */
231
+ readonly PAYMENT_CASE_DECISION: "payment_case_decision";
232
+ /**
233
+ * A scammer profile edited.
234
+ *
235
+ * 🛑 Public-facing accusation. The profile names a person or a payment
236
+ * handle, so the wording and the status are reputational — and the one
237
+ * question that follows a complaint is who wrote it.
238
+ */
239
+ readonly SCAMMER_PROFILE_UPDATE: "scammer_profile_update";
130
240
  };
131
241
  };
@@ -57,6 +57,80 @@ export const AdminAuditActionValues = {
57
57
  * returns the existing row, which is not an act worth logging.
58
58
  */
59
59
  DISPUTE_REASON_CREATE: "dispute_reason_create",
60
+ /*
61
+ * Retiring is recorded and reactivating is not, which is deliberate rather
62
+ * than an omission. Retiring REMOVES an option buyers could pick, so "who
63
+ * narrowed the list, and when" is the question an audit of a dispute trend
64
+ * needs answered. Putting a row back restores the documented default, and a
65
+ * log of both would read as churn.
66
+ */
67
+ DISPUTE_REASON_RETIRE: "dispute_reason_retire",
68
+ /**
69
+ * An admin changed a platform rate through `/admin/pricing`.
70
+ *
71
+ * 🛑 Recorded here as well as in `siteSettings.rateChanges`, and the two are
72
+ * not redundant. The rate log answers "when did THIS rate last move" and is
73
+ * capped, so it can lose its oldest entries; this answers "what have admins
74
+ * done lately" across every privileged act, and is the one that has to
75
+ * survive. Same split as `points_adjust` against its ledger.
76
+ *
77
+ * Every rate here is money the platform charges or withholds, so this is the
78
+ * same class as `coupon_update` and `payout_mark_paid`.
79
+ */
80
+ PRICING_UPDATE: "pricing_update",
81
+ /**
82
+ * An admin granted or revoked `trustedSeller` on a store.
83
+ *
84
+ * 🛑 Its OWN action rather than folded into `store_status_change`, because
85
+ * "what changed about this store" and "who accepted this risk" are
86
+ * different questions. Trusting a seller means paying before delivery, so
87
+ * the platform carries a lost parcel and a non-delivery dispute — and when
88
+ * one of those lands, the question is who decided and when.
89
+ *
90
+ * Recorded on a real change only. Re-saving an unrelated field on an
91
+ * already-trusted store writes nothing; a log of non-events stops being
92
+ * read.
93
+ */
94
+ STORE_TRUST_CHANGE: "store_trust_change",
95
+ /*
96
+ * ── Part 14 / A5 ─────────────────────────────────────────────────────────
97
+ *
98
+ * `audit-privileged-write-unlogged` found 14 admin write paths that record
99
+ * nothing. These are the action values the ones being closed now need; the
100
+ * rest stay on the ratchet until each is given one deliberately.
101
+ *
102
+ * 🛑 Each is a SEPARATE value rather than a shared `admin_write`. The log's
103
+ * question is "what have admins done lately", and an undifferentiated value
104
+ * answers it with a row that says only "something". Filtering is the whole
105
+ * point of the collection.
106
+ */
107
+ /** A role's permission set changed — the definition of who can do what. */
108
+ ROLE_CHANGE: "role_change",
109
+ /**
110
+ * A tax code's rate or HSN mapping changed.
111
+ *
112
+ * 🛑 The one STATUTORY thing an admin can edit, which is why
113
+ * `/admin/pricing` deliberately does not mirror it. A wrong rate here is a
114
+ * wrong return, so "who set this and when" is the question an assessment
115
+ * asks first.
116
+ */
117
+ TAX_CODE_CHANGE: "tax_code_change",
118
+ /** Sessions forcibly revoked — the user is signed out everywhere, now. */
119
+ SESSION_REVOKE: "session_revoke",
120
+ /** A bulk operation over many users at once. */
121
+ USER_BULK_ACTION: "user_bulk_action",
122
+ /** A moderation report resolved, dismissed or actioned. */
123
+ MODERATION_DECISION: "moderation_decision",
124
+ /** A payment case claimed or resolved — money a buyer says did not arrive. */
125
+ PAYMENT_CASE_DECISION: "payment_case_decision",
126
+ /**
127
+ * A scammer profile edited.
128
+ *
129
+ * 🛑 Public-facing accusation. The profile names a person or a payment
130
+ * handle, so the wording and the status are reputational — and the one
131
+ * question that follows a complaint is who wrote it.
132
+ */
133
+ SCAMMER_PROFILE_UPDATE: "scammer_profile_update",
60
134
  };
61
135
  export const ADMIN_AUDIT_LOG_FIELDS = {
62
136
  ACTOR_UID: "actorUid",
@@ -23,6 +23,64 @@ export type UpdateProfileInput = {
23
23
  export declare function updateUserProfile(userId: string, input: UpdateProfileInput): Promise<UserDocument>;
24
24
  export declare function getUserProfile(userId: string): Promise<UserDocument | null>;
25
25
  export declare function getUserSessions(userId: string): Promise<import("..").SessionDocument[]>;
26
+ /**
27
+ * What an account has actually DONE, counted from the collections that record
28
+ * it rather than read off `users/{uid}.stats`.
29
+ *
30
+ * 🛑 `users/{uid}.stats.*` HAS NO WRITER. Measured 2026-10-23: every occurrence
31
+ * of `stats.totalOrders` / `auctionsWon` / `itemsSold` / `reviewsCount` across
32
+ * both repos is a READ, and the only `stats.itemsSold` write in the codebase is
33
+ * `StoreRepository`'s, on the STORE document. So the stored numbers are
34
+ * whatever `users-seed-data.ts` hardcoded — the admin persona ships
35
+ * `{ totalOrders: 20, auctionsWon: 2, itemsSold: 200 }` — and a real signup gets
36
+ * `DEFAULT_USER_DATA.stats`, all zeros, which stay zero after twenty orders.
37
+ *
38
+ * A number nobody maintains is worse than no number: it is indistinguishable
39
+ * from a maintained one at every call site. These are counted live instead.
40
+ *
41
+ * ## Cost
42
+ *
43
+ * Three Firestore AGGREGATIONS, in parallel — billed at one read per 1000 index
44
+ * entries matched, so ~3 reads for any real account and flat as history grows.
45
+ * That is what makes this affordable on `GET /api/user/profile`, which the
46
+ * session hits on every page load. Never replace one with `findByUser(...).length`:
47
+ * that downloads the whole history to learn its size.
48
+ *
49
+ * ## Failure is `undefined`, never 0
50
+ *
51
+ * A failed count means "unknown", and 0 means "none" — Root Cause #72's rule.
52
+ * Callers render nothing for `undefined`, so a transient Firestore error hides
53
+ * a tile rather than telling a buyer with 30 orders that they have none.
54
+ */
55
+ export interface BuyerActivityStats {
56
+ /** Orders this account placed, all lanes. */
57
+ totalOrders?: number;
58
+ /** Orders that came from winning (or buying out) an auction. */
59
+ auctionsWon?: number;
60
+ /** Reviews this account WROTE — the set `/user/reviews` lists. */
61
+ reviewsCount?: number;
62
+ }
63
+ /**
64
+ * 🛑 React.cache-wrapped: `/api/user/profile` and the public profile page can
65
+ * both ask for the same uid inside one request, and three aggregations is a
66
+ * cost worth paying once.
67
+ */
68
+ export declare const getBuyerActivityStats: (uid: string) => Promise<BuyerActivityStats>;
69
+ /**
70
+ * Buyer activity plus, for an account that owns a store, the one SELLER number
71
+ * that is genuinely maintained.
72
+ *
73
+ * `stores/{storeId}.stats.itemsSold` has real writers — `incrementItemsSold`
74
+ * from the fulfilment trigger and `setStats` from the nightly reconciliation —
75
+ * unlike its namesake on the user document. It is read here, from the store, so
76
+ * a seller's PUBLIC profile can keep showing it honestly.
77
+ *
78
+ * It deliberately does NOT reach the sidebar's own profile block, which is the
79
+ * buyer's view of their own account: see `AppLayoutShell`.
80
+ */
81
+ export declare function getProfileStats(user: Pick<UserDocument, "uid" | "id" | "storeId">): Promise<BuyerActivityStats & {
82
+ itemsSold?: number;
83
+ }>;
26
84
  /**
27
85
  * Resolve a profile by slug, then document id, then Auth uid.
28
86
  *
@@ -51,8 +109,18 @@ export declare function resolveProfileUser(identifier: string): Promise<UserDocu
51
109
  * page + generateMetadata pair is memoised per argument list, so the second call
52
110
  * is free. Note it caches the NARROW public projection, which is what both
53
111
  * callers want; anything needing the whole document still goes to the repository.
112
+ *
113
+ * 🛑 `stats` is DERIVED by `getProfileStats`, not `user.stats`. The stored field
114
+ * has no writer and is seed fiction — see `getBuyerActivityStats`. It is derived
115
+ * here as well as on the session endpoint deliberately: fixing only the sidebar
116
+ * would have left a signed-in user reading real counts in the nav and seeded
117
+ * ones on their own public profile, which is a worse state than both being wrong.
54
118
  */
55
- export declare const getPublicUserProfile: (userId: string) => Promise<Pick<UserDocument, "id" | "slug" | "displayName" | "photoURL" | "role" | "createdAt" | "storeId" | "storeSlug" | "publicProfile" | "stats"> | null>;
119
+ export declare const getPublicUserProfile: (userId: string) => Promise<(Pick<UserDocument, "id" | "slug" | "displayName" | "photoURL" | "role" | "createdAt" | "storeId" | "storeSlug" | "publicProfile"> & {
120
+ stats: BuyerActivityStats & {
121
+ itemsSold?: number;
122
+ };
123
+ }) | null>;
56
124
  /** Fetch approved reviews for a store. storeId === storeSlug in this project. */
57
125
  export declare function getSellerReviews(storeId: string): Promise<Review[]>;
58
126
  export declare function getProfileStoreProducts(storeId: string): Promise<import("../../products").ProductDocument[]>;
@@ -12,6 +12,8 @@ import { sessionRepository } from "../repository/session.repository.js";
12
12
  import { productRepository } from "../../products/repository/products.repository.js";
13
13
  import { ProductStatusValues } from "../../products/schemas/index.js";
14
14
  import { reviewRepository } from "../../reviews/repository/reviews.repository.js";
15
+ import { orderRepository } from "../../orders/repository/orders.repository.js";
16
+ import { storeRepository } from "../../stores/repository/store.repository.js";
15
17
  export async function updateUserProfile(userId, input) {
16
18
  const finalPhotoURL = await finalizeStagedMediaField(input.photoURL);
17
19
  const data = finalPhotoURL !== undefined ? { ...input, photoURL: finalPhotoURL } : input;
@@ -23,6 +25,59 @@ export async function getUserProfile(userId) {
23
25
  export async function getUserSessions(userId) {
24
26
  return sessionRepository.findAllByUser(userId);
25
27
  }
28
+ /**
29
+ * 🛑 React.cache-wrapped: `/api/user/profile` and the public profile page can
30
+ * both ask for the same uid inside one request, and three aggregations is a
31
+ * cost worth paying once.
32
+ */
33
+ export const getBuyerActivityStats = cache(async function getBuyerActivityStats(uid) {
34
+ const route = "profile:buyer-activity-stats";
35
+ const [totalOrders, auctionsWon, reviewsCount] = await Promise.all([
36
+ safeRead(() => orderRepository.countForUser(uid), {
37
+ route,
38
+ key: "orders:total",
39
+ fallback: undefined,
40
+ }),
41
+ safeRead(() => orderRepository.countForUser(uid, "auction"), {
42
+ route,
43
+ key: "orders:auction",
44
+ fallback: undefined,
45
+ }),
46
+ safeRead(() => reviewRepository.countByUser(uid), {
47
+ route,
48
+ key: "reviews:written",
49
+ fallback: undefined,
50
+ }),
51
+ ]);
52
+ return { totalOrders, auctionsWon, reviewsCount };
53
+ });
54
+ /**
55
+ * Buyer activity plus, for an account that owns a store, the one SELLER number
56
+ * that is genuinely maintained.
57
+ *
58
+ * `stores/{storeId}.stats.itemsSold` has real writers — `incrementItemsSold`
59
+ * from the fulfilment trigger and `setStats` from the nightly reconciliation —
60
+ * unlike its namesake on the user document. It is read here, from the store, so
61
+ * a seller's PUBLIC profile can keep showing it honestly.
62
+ *
63
+ * It deliberately does NOT reach the sidebar's own profile block, which is the
64
+ * buyer's view of their own account: see `AppLayoutShell`.
65
+ */
66
+ export async function getProfileStats(user) {
67
+ const uid = user.uid ?? user.id;
68
+ const [activity, itemsSold] = await Promise.all([
69
+ getBuyerActivityStats(uid),
70
+ user.storeId
71
+ ? safeRead(async () => (await storeRepository.findById(user.storeId))?.stats
72
+ ?.itemsSold, {
73
+ route: "profile:store-items-sold",
74
+ key: "store:itemsSold",
75
+ fallback: undefined,
76
+ })
77
+ : Promise.resolve(undefined),
78
+ ]);
79
+ return { ...activity, itemsSold };
80
+ }
26
81
  /**
27
82
  * Resolve a profile by slug, then document id, then Auth uid.
28
83
  *
@@ -59,6 +114,12 @@ export async function resolveProfileUser(identifier) {
59
114
  * page + generateMetadata pair is memoised per argument list, so the second call
60
115
  * is free. Note it caches the NARROW public projection, which is what both
61
116
  * callers want; anything needing the whole document still goes to the repository.
117
+ *
118
+ * 🛑 `stats` is DERIVED by `getProfileStats`, not `user.stats`. The stored field
119
+ * has no writer and is seed fiction — see `getBuyerActivityStats`. It is derived
120
+ * here as well as on the session endpoint deliberately: fixing only the sidebar
121
+ * would have left a signed-in user reading real counts in the nav and seeded
122
+ * ones on their own public profile, which is a worse state than both being wrong.
62
123
  */
63
124
  export const getPublicUserProfile = cache(async function getPublicUserProfile(userId) {
64
125
  const user = await resolveProfileUser(userId);
@@ -74,7 +135,7 @@ export const getPublicUserProfile = cache(async function getPublicUserProfile(us
74
135
  storeId: user.storeId,
75
136
  storeSlug: user.storeSlug,
76
137
  publicProfile: user.publicProfile,
77
- stats: user.stats,
138
+ stats: await getProfileStats(user),
78
139
  };
79
140
  });
80
141
  /** Fetch approved reviews for a store. storeId === storeSlug in this project. */