@mohasinac/appkit 4.2.0 → 4.3.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 (258) hide show
  1. package/dist/_internal/client/features/layout/navActive.d.ts +17 -0
  2. package/dist/_internal/client/features/layout/navActive.js +33 -0
  3. package/dist/_internal/server/features/auth/actions.d.ts +0 -1
  4. package/dist/_internal/server/features/auth/actions.js +0 -1
  5. package/dist/_internal/server/features/bundles/data.d.ts +22 -4
  6. package/dist/_internal/server/features/bundles/data.js +41 -4
  7. package/dist/_internal/server/features/bundles/index.d.ts +1 -1
  8. package/dist/_internal/server/features/bundles/index.js +1 -1
  9. package/dist/_internal/server/features/checkout/actions.js +7 -78
  10. package/dist/_internal/server/features/checkout/prize-bundle-gates.d.ts +5 -16
  11. package/dist/_internal/server/features/checkout/prize-bundle-gates.js +5 -30
  12. package/dist/_internal/server/features/orders/adapters.js +1 -14
  13. package/dist/_internal/server/features/products/actions.js +8 -3
  14. package/dist/_internal/server/features/products/service.d.ts +15 -0
  15. package/dist/_internal/server/features/products/service.js +41 -0
  16. package/dist/_internal/server/functions/firestore.d.ts +3 -1
  17. package/dist/_internal/server/functions/firestore.js +17 -1
  18. package/dist/_internal/server/functions/scheduled.d.ts +2 -5
  19. package/dist/_internal/server/functions/scheduled.js +6 -30
  20. package/dist/_internal/server/jobs/core/bundleStockSync.js +25 -7
  21. package/dist/_internal/server/jobs/core/countersReconcile.js +7 -1
  22. package/dist/_internal/server/jobs/core/index.d.ts +4 -4
  23. package/dist/_internal/server/jobs/core/index.js +4 -4
  24. package/dist/_internal/server/jobs/core/onPrizeDrawPaymentConfirmed.d.ts +19 -0
  25. package/dist/_internal/server/jobs/core/onPrizeDrawPaymentConfirmed.js +30 -0
  26. package/dist/_internal/server/jobs/core/paymentWindowTimeout.d.ts +2 -2
  27. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +2 -2
  28. package/dist/_internal/server/jobs/core/prizeDrawAssignWinner.d.ts +20 -0
  29. package/dist/_internal/server/jobs/core/prizeDrawAssignWinner.js +113 -0
  30. package/dist/_internal/server/jobs/core/prizeDrawExpiryReveal.d.ts +14 -0
  31. package/dist/_internal/server/jobs/core/prizeDrawExpiryReveal.js +74 -0
  32. package/dist/_internal/server/jobs/core/prizeDrawSoldOutReveal.d.ts +22 -0
  33. package/dist/_internal/server/jobs/core/prizeDrawSoldOutReveal.js +68 -0
  34. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -4
  35. package/dist/_internal/server/jobs/handlers/index.js +8 -6
  36. package/dist/_internal/server/jobs/handlers/onPrizeDrawPaymentConfirmed.d.ts +3 -0
  37. package/dist/_internal/server/jobs/handlers/onPrizeDrawPaymentConfirmed.js +11 -0
  38. package/dist/_internal/server/jobs/handlers/prizeDrawExpiryReveal.d.ts +2 -0
  39. package/dist/_internal/server/jobs/handlers/prizeDrawExpiryReveal.js +2 -0
  40. package/dist/_internal/server/jobs/handlers/prizeDrawSoldOutReveal.d.ts +3 -0
  41. package/dist/_internal/server/jobs/handlers/prizeDrawSoldOutReveal.js +4 -0
  42. package/dist/_internal/shared/actions/action-registry.js +40 -10
  43. package/dist/_internal/shared/checkout/rules/_defaults.js +1 -2
  44. package/dist/_internal/shared/checkout/rules/_limits.d.ts +3 -0
  45. package/dist/_internal/shared/checkout/rules/_limits.js +3 -0
  46. package/dist/_internal/shared/checkout/rules/_registry.d.ts +2 -1
  47. package/dist/_internal/shared/checkout/rules/_registry.js +2 -2
  48. package/dist/_internal/shared/checkout/rules/classified.rule.js +0 -1
  49. package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
  50. package/dist/_internal/shared/checkout/rules/index.js +1 -1
  51. package/dist/_internal/shared/checkout/rules/prize-draw.rule.js +20 -1
  52. package/dist/_internal/shared/checkout/rules/types.d.ts +17 -7
  53. package/dist/_internal/shared/features/categories/bundle-copy.d.ts +2 -0
  54. package/dist/_internal/shared/features/categories/bundle-copy.js +2 -0
  55. package/dist/_internal/shared/features/categories/bundle-pricing.d.ts +23 -0
  56. package/dist/_internal/shared/features/categories/bundle-pricing.js +25 -0
  57. package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
  58. package/dist/constants/api-endpoints.d.ts +6 -9
  59. package/dist/constants/api-endpoints.js +5 -5
  60. package/dist/constants/field-names.d.ts +17 -1
  61. package/dist/constants/field-names.js +17 -1
  62. package/dist/features/account/components/UserSidebar.js +21 -9
  63. package/dist/features/account/hooks/useNotifications.js +5 -0
  64. package/dist/features/admin/actions/admin-actions.js +6 -0
  65. package/dist/features/admin/actions/notification-actions.js +1 -2
  66. package/dist/features/admin/components/AdminBlogEditorView.js +1 -1
  67. package/dist/features/admin/components/AdminGuideHubView.js +15 -1
  68. package/dist/features/admin/components/AdminPaymentsGuideView.d.ts +2 -0
  69. package/dist/features/admin/components/AdminPaymentsGuideView.js +36 -0
  70. package/dist/features/admin/components/AdminPrizeDrawsView.js +3 -3
  71. package/dist/features/admin/components/AdminSidebar.js +18 -9
  72. package/dist/features/admin/components/AdminSiteSettingsView.js +33 -6
  73. package/dist/features/admin/components/AdminTesterChecklistView.js +74 -22
  74. package/dist/features/admin/components/AdminWhatsAppGuideView.d.ts +2 -0
  75. package/dist/features/admin/components/AdminWhatsAppGuideView.js +35 -0
  76. package/dist/features/admin/hooks/useAdminListing.d.ts +4 -0
  77. package/dist/features/admin/hooks/useAdminListing.js +4 -2
  78. package/dist/features/admin/schemas/firestore.d.ts +13 -5
  79. package/dist/features/admin/schemas/firestore.js +1 -2
  80. package/dist/features/auctions/actions/bid-actions.js +3 -1
  81. package/dist/features/auctions/components/AuctionBidsTable.d.ts +6 -1
  82. package/dist/features/auctions/components/AuctionBidsTable.js +8 -4
  83. package/dist/features/auctions/components/AuctionDetailPageView.js +10 -13
  84. package/dist/features/auctions/components/CollapsibleBidHistory.d.ts +23 -2
  85. package/dist/features/auctions/components/CollapsibleBidHistory.js +22 -3
  86. package/dist/features/auctions/components/LiveBidPrice.d.ts +19 -0
  87. package/dist/features/auctions/components/LiveBidPrice.js +17 -0
  88. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
  89. package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
  90. package/dist/features/auctions/hooks/useBids.d.ts +33 -0
  91. package/dist/features/auctions/hooks/useBids.js +32 -0
  92. package/dist/features/auctions/hooks/useLiveAuctionBid.d.ts +20 -0
  93. package/dist/features/auctions/hooks/useLiveAuctionBid.js +23 -0
  94. package/dist/features/auth/checkout-value-otp.d.ts +7 -10
  95. package/dist/features/auth/checkout-value-otp.js +14 -10
  96. package/dist/features/auth/server.d.ts +0 -1
  97. package/dist/features/auth/server.js +0 -1
  98. package/dist/features/blog/api/[slug]/route.d.ts +5 -0
  99. package/dist/features/blog/api/[slug]/route.js +12 -3
  100. package/dist/features/blog/components/BlogPostView.d.ts +2 -0
  101. package/dist/features/blog/components/BlogPostView.js +5 -4
  102. package/dist/features/blog/hooks/useBlog.d.ts +2 -0
  103. package/dist/features/blog/hooks/useBlog.js +2 -0
  104. package/dist/features/blog/repository/blog.repository.d.ts +7 -0
  105. package/dist/features/blog/repository/blog.repository.js +44 -0
  106. package/dist/features/blog/schemas/firestore.d.ts +3 -0
  107. package/dist/features/blog/schemas/firestore.js +1 -0
  108. package/dist/features/blog/types/index.d.ts +2 -0
  109. package/dist/features/cart/components/index.d.ts +0 -2
  110. package/dist/features/cart/components/index.js +0 -1
  111. package/dist/features/cart/hooks/useCartCount.js +22 -5
  112. package/dist/features/cart/hooks/useGuestCart.js +11 -1
  113. package/dist/features/cart/utils/guest-cart.d.ts +4 -0
  114. package/dist/features/cart/utils/guest-cart.js +10 -0
  115. package/dist/features/cart/utils/pending-ops.d.ts +17 -0
  116. package/dist/features/cart/utils/pending-ops.js +46 -5
  117. package/dist/features/categories/components/BrandDetailPageView.js +6 -2
  118. package/dist/features/categories/components/BundleDetailView.js +4 -2
  119. package/dist/features/categories/components/CategoryDetailPageView.js +10 -2
  120. package/dist/features/categories/schemas/firestore.d.ts +7 -0
  121. package/dist/features/checkout/actions/index.d.ts +0 -1
  122. package/dist/features/checkout/actions/index.js +0 -1
  123. package/dist/features/checkout/schemas/firestore.d.ts +2 -2
  124. package/dist/features/events/actions/event-actions.d.ts +2 -0
  125. package/dist/features/events/actions/event-actions.js +5 -0
  126. package/dist/features/events/components/EventCard.js +2 -3
  127. package/dist/features/events/components/RelatedEventsCarousel.d.ts +15 -0
  128. package/dist/features/events/components/RelatedEventsCarousel.js +13 -0
  129. package/dist/features/events/components/index.d.ts +2 -0
  130. package/dist/features/events/components/index.js +1 -0
  131. package/dist/features/events/repository/events.repository.d.ts +6 -0
  132. package/dist/features/events/repository/events.repository.js +21 -0
  133. package/dist/features/history/repository/user-history.repository.d.ts +1 -1
  134. package/dist/features/history/utils/guest-history.d.ts +1 -1
  135. package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -2
  136. package/dist/features/homepage/components/FeaturedProductsSection.js +2 -2
  137. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  138. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  139. package/dist/features/layout/AppLayoutShell.js +44 -10
  140. package/dist/features/layout/NavbarLayout.js +1 -1
  141. package/dist/features/layout/TitleBarLayout.js +2 -2
  142. package/dist/features/media/MediaVideo.d.ts +7 -0
  143. package/dist/features/media/MediaVideo.js +36 -5
  144. package/dist/features/orders/components/OrdersList.js +1 -5
  145. package/dist/features/orders/schemas/firestore.d.ts +2 -8
  146. package/dist/features/orders/schemas/index.d.ts +3 -32
  147. package/dist/features/orders/schemas/index.js +1 -4
  148. package/dist/features/orders/types/index.d.ts +6 -9
  149. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  150. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  151. package/dist/features/products/actions/product-actions.js +13 -10
  152. package/dist/features/products/components/MarketplaceBundleCard.d.ts +1 -1
  153. package/dist/features/products/components/MarketplaceBundleCard.js +7 -4
  154. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  155. package/dist/features/products/components/PrizeDrawItemsEditor.js +2 -2
  156. package/dist/features/products/components/PrizeDrawWinnerMappingView.d.ts +14 -0
  157. package/dist/features/products/components/PrizeDrawWinnerMappingView.js +37 -0
  158. package/dist/features/products/components/PrizeRevealModal.d.ts +10 -23
  159. package/dist/features/products/components/PrizeRevealModal.js +18 -147
  160. package/dist/features/products/components/ProductDetailPageView.js +72 -30
  161. package/dist/features/products/components/ProductForm.js +7 -14
  162. package/dist/features/products/components/ProductGalleryClient.d.ts +10 -1
  163. package/dist/features/products/components/ProductGalleryClient.js +29 -10
  164. package/dist/features/products/components/ProductsIndexListing.js +2 -1
  165. package/dist/features/products/components/RelatedProductsCarousel.js +2 -2
  166. package/dist/features/products/components/ShowGroupSection.js +2 -2
  167. package/dist/features/products/components/SublistingCarouselSection.js +1 -1
  168. package/dist/features/products/components/index.d.ts +4 -2
  169. package/dist/features/products/components/index.js +1 -0
  170. package/dist/features/products/constants/action-defs.d.ts +1 -3
  171. package/dist/features/products/constants/action-defs.js +0 -5
  172. package/dist/features/products/repository/products.repository.d.ts +6 -0
  173. package/dist/features/products/repository/products.repository.js +20 -1
  174. package/dist/features/products/schemas/firestore.d.ts +28 -3
  175. package/dist/features/products/schemas/index.d.ts +11 -3
  176. package/dist/features/products/schemas/index.js +10 -2
  177. package/dist/features/products/types/index.d.ts +2 -1
  178. package/dist/features/reviews/actions/review-actions.js +1 -0
  179. package/dist/features/reviews/components/ReviewDetailPageView.js +8 -1
  180. package/dist/features/reviews/components/ReviewDetailShell.js +2 -1
  181. package/dist/features/reviews/components/ReviewsList.js +2 -1
  182. package/dist/features/reviews/schemas/firestore.d.ts +3 -0
  183. package/dist/features/reviews/schemas/index.d.ts +6 -0
  184. package/dist/features/reviews/schemas/index.js +2 -0
  185. package/dist/features/reviews/types/index.d.ts +3 -0
  186. package/dist/features/scams/actions/scam-actions.d.ts +3 -0
  187. package/dist/features/scams/actions/scam-actions.js +3 -2
  188. package/dist/features/scams/components/ScamProfileView.d.ts +2 -1
  189. package/dist/features/scams/components/ScamProfileView.js +2 -2
  190. package/dist/features/scams/repository/scammer.repository.d.ts +6 -0
  191. package/dist/features/scams/repository/scammer.repository.js +24 -0
  192. package/dist/features/seller/actions/seller-actions.js +8 -0
  193. package/dist/features/seller/components/SellerPrizeDrawsView.js +4 -0
  194. package/dist/features/seller/components/SellerSidebar.js +20 -9
  195. package/dist/features/stores/components/StoreDetailLayoutView.js +6 -6
  196. package/dist/features/stores/components/StoreGuideHubView.js +7 -1
  197. package/dist/features/stores/components/StoreNavTabs.d.ts +10 -1
  198. package/dist/features/stores/components/StoreNavTabs.js +22 -9
  199. package/dist/features/stores/components/StoreWhatsAppGuideView.d.ts +3 -0
  200. package/dist/features/stores/components/StoreWhatsAppGuideView.js +12 -0
  201. package/dist/features/stores/components/index.d.ts +2 -0
  202. package/dist/features/stores/components/index.js +1 -0
  203. package/dist/features/tester/actions/index.d.ts +1 -0
  204. package/dist/features/tester/actions/index.js +1 -0
  205. package/dist/features/tester/actions/leaderboard-actions.d.ts +3 -0
  206. package/dist/features/tester/actions/leaderboard-actions.js +5 -0
  207. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +26 -4
  208. package/dist/features/tester/components/AdminTesterFeedbackListView.js +13 -0
  209. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +23 -0
  210. package/dist/features/tester/components/BugHunterLeaderboardView.js +18 -0
  211. package/dist/features/tester/components/index.d.ts +2 -0
  212. package/dist/features/tester/components/index.js +1 -0
  213. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +12 -1
  214. package/dist/features/tester/repository/tester-checklist-item.repository.js +80 -0
  215. package/dist/features/tester/repository/tester-checklist-response.repository.d.ts +4 -0
  216. package/dist/features/tester/repository/tester-checklist-response.repository.js +4 -0
  217. package/dist/features/tester/schemas/firestore.d.ts +22 -1
  218. package/dist/features/tester/schemas/firestore.js +8 -0
  219. package/dist/features/tester/seed-data/categories-tester-seed-data.js +42 -7
  220. package/dist/features/tester/seed-data/events-tester-seed-data.js +23 -0
  221. package/dist/features/tester/seed-data/orders-tester-seed-data.js +69 -31
  222. package/dist/features/tester/seed-data/products-tester-seed-data.js +154 -9
  223. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +252 -15
  224. package/dist/features/wishlist/hooks/useGuestWishlist.js +11 -1
  225. package/dist/features/wishlist/hooks/useWishlistCount.js +16 -2
  226. package/dist/features/wishlist/utils/guest-wishlist.d.ts +5 -0
  227. package/dist/features/wishlist/utils/guest-wishlist.js +11 -0
  228. package/dist/index.d.ts +23 -22
  229. package/dist/index.js +31 -58
  230. package/dist/jobs.d.ts +1 -1
  231. package/dist/jobs.js +2 -2
  232. package/dist/next/routing/route-map.d.ts +9 -2
  233. package/dist/next/routing/route-map.js +4 -1
  234. package/dist/schemas/registry.d.ts +6 -26
  235. package/dist/seed/bids-seed-data.js +14 -2
  236. package/dist/seed/blog-posts-seed-data.js +2 -1
  237. package/dist/seed/categories-seed-data.js +11 -2
  238. package/dist/seed/events-seed-data.js +11 -11
  239. package/dist/seed/faq-seed-data.js +5 -1
  240. package/dist/seed/products-auctions-seed-data.js +2 -2
  241. package/dist/seed/products-preorders-seed-data.d.ts +2 -1
  242. package/dist/seed/products-prize-draws-seed-data.js +56 -10
  243. package/dist/seed/products-standard-seed-data.js +80 -3
  244. package/dist/seed/scammers-seed-data.js +75 -2
  245. package/dist/seed/site-settings-seed-data.js +5 -2
  246. package/dist/server-entry.d.ts +0 -1
  247. package/dist/server-entry.js +0 -1
  248. package/dist/server.d.ts +4 -20
  249. package/dist/server.js +10 -57
  250. package/dist/styles.css +1 -277
  251. package/dist/tailwind-utilities.css +1 -1
  252. package/dist/ui/components/BaseListingCard.js +1 -1
  253. package/dist/ui/components/ImageLightbox.d.ts +9 -0
  254. package/dist/ui/components/ImageLightbox.js +6 -3
  255. package/dist/ui/components/ListingToolbar.d.ts +18 -1
  256. package/dist/ui/components/ListingToolbar.js +5 -2
  257. package/dist/ui/components/StickyToolbar.js +13 -8
  258. package/package.json +1 -1
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared "which nav item/group is active" algorithm for the Admin/Store/User
3
+ * dashboard sidebars and the public AppLayoutShell sidebar. Longest matching
4
+ * href wins (rather than every prefix-matching item lighting up independently)
5
+ * so a short root route like "/store" or "/user" (each sidebar's own
6
+ * "Dashboard" item) never lights up alongside a real, more specific match —
7
+ * see CLAUDE.md Recurrent Root Cause Patterns for the incident this fixes.
8
+ */
9
+ export declare function findActiveNavItem<Item extends {
10
+ href: string;
11
+ }>(items: Item[], activeHref: string): Item | undefined;
12
+ export declare function findActiveNavGroup<Item extends {
13
+ href: string;
14
+ }, Group extends {
15
+ title: string;
16
+ items: Item[];
17
+ }>(groups: Group[], activeHref: string): Group | undefined;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared "which nav item/group is active" algorithm for the Admin/Store/User
3
+ * dashboard sidebars and the public AppLayoutShell sidebar. Longest matching
4
+ * href wins (rather than every prefix-matching item lighting up independently)
5
+ * so a short root route like "/store" or "/user" (each sidebar's own
6
+ * "Dashboard" item) never lights up alongside a real, more specific match —
7
+ * see CLAUDE.md Recurrent Root Cause Patterns for the incident this fixes.
8
+ */
9
+ function isPrefixMatch(href, activeHref) {
10
+ return activeHref === href || activeHref.startsWith(`${href}/`);
11
+ }
12
+ export function findActiveNavItem(items, activeHref) {
13
+ let best;
14
+ for (const item of items) {
15
+ if (!isPrefixMatch(item.href, activeHref))
16
+ continue;
17
+ if (!best || item.href.length > best.href.length)
18
+ best = item;
19
+ }
20
+ return best;
21
+ }
22
+ export function findActiveNavGroup(groups, activeHref) {
23
+ let bestGroup;
24
+ let bestLen = -1;
25
+ for (const group of groups) {
26
+ const item = findActiveNavItem(group.items, activeHref);
27
+ if (item && item.href.length > bestLen) {
28
+ bestGroup = group;
29
+ bestLen = item.href.length;
30
+ }
31
+ }
32
+ return bestGroup;
33
+ }
@@ -5,4 +5,3 @@
5
5
  * route handler.
6
6
  */
7
7
  export { authMeGET } from "../../../../features/auth/api/route";
8
- export * from "../../../../features/auth/consent-otp";
@@ -5,4 +5,3 @@
5
5
  * route handler.
6
6
  */
7
7
  export { authMeGET } from "../../../../features/auth/api/route";
8
- export * from "../../../../features/auth/consent-otp";
@@ -17,11 +17,29 @@ export interface BundleDataOptions {
17
17
  */
18
18
  export declare const getBundleForDetail: (slug: string, _opts?: BundleDataOptions) => Promise<CategoryDocument | null>;
19
19
  /**
20
- * Fetch the resolved product members of a bundle. Reads `bundleProductIds`
21
- * off the bundle document and hydrates each via `productRepository.findById`.
22
- * Returns an empty array when the bundle has no product mirror yet.
20
+ * Resolve the effective member product ids for a bundle. `bundleProductIds`
21
+ * is the index-friendly mirror and is normally authoritative, but a bundle
22
+ * can be written (e.g. by seed data or a partial API payload) with only its
23
+ * `bundleQueryRule` set and no mirror — falls back to the static rule's
24
+ * `productIds` in that case rather than silently rendering an empty bundle.
23
25
  */
24
- export declare const listBundleMembers: (bundle: Pick<CategoryDocument, "bundleProductIds">, _opts?: BundleDataOptions) => Promise<ProductDocument[]>;
26
+ export declare function resolveBundleMemberIds(bundle: Pick<CategoryDocument, "bundleProductIds" | "bundleQueryRule">): string[];
27
+ /**
28
+ * Fetch the resolved product members of a bundle. Hydrates each id from
29
+ * `resolveBundleMemberIds` via `productRepository.findById`. Returns an
30
+ * empty array when the bundle has no resolvable members yet.
31
+ */
32
+ export declare const listBundleMembers: (bundle: Pick<CategoryDocument, "bundleProductIds" | "bundleQueryRule">, _opts?: BundleDataOptions) => Promise<ProductDocument[]>;
33
+ /**
34
+ * Sum the individual prices of a set of member products — the "buy
35
+ * separately" total a bundle's discount is measured against. Used at bundle
36
+ * create/update time (and by the daily `runBundleStockSync` sweep) to
37
+ * populate `bundleOriginalTotal`. Deliberately returns `undefined` (rather
38
+ * than a partial sum) when any member id fails to resolve, so a stale or
39
+ * broken reference never understates the "before" price and inflates the
40
+ * displayed discount.
41
+ */
42
+ export declare function resolveBundleOriginalTotal(productIds: string[]): Promise<number | undefined>;
25
43
  /**
26
44
  * List the most recent active bundles for homepage placement. Bounded to
27
45
  * `limit` (default 8) and filtered to `isActive: true` server-side.
@@ -19,18 +19,55 @@ export const getBundleForDetail = cache(async (slug, _opts) => {
19
19
  return categoriesRepository.findBySlugAndType(slug, "bundle");
20
20
  });
21
21
  /**
22
- * Fetch the resolved product members of a bundle. Reads `bundleProductIds`
23
- * off the bundle document and hydrates each via `productRepository.findById`.
24
- * Returns an empty array when the bundle has no product mirror yet.
22
+ * Resolve the effective member product ids for a bundle. `bundleProductIds`
23
+ * is the index-friendly mirror and is normally authoritative, but a bundle
24
+ * can be written (e.g. by seed data or a partial API payload) with only its
25
+ * `bundleQueryRule` set and no mirror — falls back to the static rule's
26
+ * `productIds` in that case rather than silently rendering an empty bundle.
27
+ */
28
+ export function resolveBundleMemberIds(bundle) {
29
+ if (bundle.bundleProductIds?.length)
30
+ return bundle.bundleProductIds;
31
+ if (bundle.bundleQueryRule?.type === "static") {
32
+ return bundle.bundleQueryRule.productIds;
33
+ }
34
+ return [];
35
+ }
36
+ /**
37
+ * Fetch the resolved product members of a bundle. Hydrates each id from
38
+ * `resolveBundleMemberIds` via `productRepository.findById`. Returns an
39
+ * empty array when the bundle has no resolvable members yet.
25
40
  */
26
41
  export const listBundleMembers = cache(async (bundle, _opts) => {
27
42
  void _opts;
28
- const ids = bundle.bundleProductIds ?? [];
43
+ const ids = resolveBundleMemberIds(bundle);
29
44
  if (ids.length === 0)
30
45
  return [];
31
46
  const results = await Promise.all(ids.map((id) => productRepository.findById(id).catch(() => null)));
32
47
  return results.filter((p) => p !== null);
33
48
  });
49
+ /**
50
+ * Sum the individual prices of a set of member products — the "buy
51
+ * separately" total a bundle's discount is measured against. Used at bundle
52
+ * create/update time (and by the daily `runBundleStockSync` sweep) to
53
+ * populate `bundleOriginalTotal`. Deliberately returns `undefined` (rather
54
+ * than a partial sum) when any member id fails to resolve, so a stale or
55
+ * broken reference never understates the "before" price and inflates the
56
+ * displayed discount.
57
+ */
58
+ export async function resolveBundleOriginalTotal(productIds) {
59
+ if (productIds.length === 0)
60
+ return undefined;
61
+ const results = await Promise.all(productIds.map((id) => productRepository.findById(id).catch(() => null)));
62
+ const prices = [];
63
+ for (const p of results) {
64
+ if (!p || typeof p.price !== "number")
65
+ return undefined;
66
+ prices.push(p.price);
67
+ }
68
+ const total = prices.reduce((sum, p) => sum + p, 0);
69
+ return Math.round(total * 100) / 100;
70
+ }
34
71
  /**
35
72
  * List the most recent active bundles for homepage placement. Bounded to
36
73
  * `limit` (default 8) and filtered to `isActive: true` server-side.
@@ -1,4 +1,4 @@
1
- export { getBundleForDetail, listBundleMembers, listFeaturedBundles, type BundleDataOptions, } from "./data";
1
+ export { getBundleForDetail, listBundleMembers, listFeaturedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, type BundleDataOptions, } from "./data";
2
2
  export { buildBundleMetadata, type BundleMetadataOptions } from "./metadata";
3
3
  export { renderBundleOg, renderBundleOgImage, type BundleOgData, } from "./og";
4
4
  export { addBundleToCartAction } from "./actions";
@@ -1,4 +1,4 @@
1
- export { getBundleForDetail, listBundleMembers, listFeaturedBundles, } from "./data";
1
+ export { getBundleForDetail, listBundleMembers, listFeaturedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, } from "./data";
2
2
  export { buildBundleMetadata } from "./metadata";
3
3
  // S-SBUNI-4 2026-05-13 — bundle OG renderer.
4
4
  export { renderBundleOg, renderBundleOgImage, } from "./og";
@@ -1,5 +1,4 @@
1
1
  import { normalizeError } from "../../../../errors/normalize";
2
- import { safeFireAndForget } from "../../../../utils/safe-fire-forget";
3
2
  import { roundRupees } from "../../../../utils/number.formatter";
4
3
  /**
5
4
  * Checkout server actions (appkit).
@@ -18,21 +17,19 @@ import { computePreOrderDepositAmount } from "../../../shared/checkout/order-mat
18
17
  import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
19
18
  import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
20
19
  import { splitCartIntoOrderGroups } from "../../../../features/orders/index";
21
- import { resolveDate } from "../../../../utils";
22
20
  import { computeCodHandlingFee, computeWhatsAppNotifyFee, computeGiftWrapFee, computeShipmentProtectionFee, } from "../../../shared/fees/calculator";
23
21
  import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase";
24
22
  import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore";
25
23
  import { CART_COLLECTION } from "../../../../features/cart/schemas/index";
26
24
  // S-SBUNI-RULES 2026-05-13 — bundle cart-line stock fan-out helpers.
27
25
  import { getCartItemMemberIds, getExpandedDecrements, bucketCartItemsByStock, } from "./bundle-expansion";
28
- import { consentOtpRef, consentOtpRateLimitRef, CONSENT_OTP_MAX_BYPASS_CREDITS, } from "../../../../features/auth/server";
29
26
  import { OrderStatusValues, PaymentStatusValues, PaymentMethodValues, OutOfStockPolicyValues, } from "../../../../features/orders/schemas/index";
30
27
  import { getDefaultCurrency } from "../../../../core/index";
31
28
  import { verifyPaymentSignatureWithKeys, fetchRazorpayOrder, paiseToRupees, } from "../../../../providers/payment-razorpay/index";
32
29
  import { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_DEFAULT_EMI_SETTINGS } from "../../../shared/features/checkout/config";
33
30
  import { checkEmiEligibility, computeEmiSchedule } from "../../../shared/features/emi/schedule";
34
31
  import { formatShippingAddress } from "./data";
35
- import { enforceMaxPerUserForCart, computePrizeRevealDeadline, } from "./prize-bundle-gates";
32
+ import { enforceMaxPerUserForCart } from "./prize-bundle-gates";
36
33
  import { getListingRule, runSyncPreflight, } from "../../../shared/checkout/rules";
37
34
  import { cartIsDigitalOnly } from "../../../shared/listing-types/cart-shipping";
38
35
  import { processRefundAction } from "../refunds/actions";
@@ -215,18 +212,6 @@ async function flushCouponUsageAccumulator(accumulator, uid) {
215
212
  serverLogger.error("Failed to record coupon usage:", { err: err instanceof Error ? err.message : String(err) });
216
213
  }
217
214
  }
218
- function grantConsentOtpBypassCredit(db, uid, emailOtpUsed, unavailableCount) {
219
- if (!emailOtpUsed || unavailableCount === 0)
220
- return;
221
- const metaRef = consentOtpRateLimitRef(db, uid);
222
- metaRef
223
- .get()
224
- .then((snap) => {
225
- const cur = snap.exists ? (snap.data()?.bypassCredits ?? 0) : 0;
226
- return metaRef.set({ bypassCredits: Math.min(cur + 1, CONSENT_OTP_MAX_BYPASS_CREDITS) }, { merge: true });
227
- })
228
- .catch((err) => serverLogger.warn("Failed to grant consent OTP bypass credit", { err }));
229
- }
230
215
  function dispatchOrderConfirmationEmails(emailsToSend) {
231
216
  if (emailsToSend.length === 0)
232
217
  return;
@@ -382,9 +367,6 @@ async function createOrderForGroup(group, orderType, ctx) {
382
367
  const groupRule = getListingRule(lt0);
383
368
  const extraOrderFields = {
384
369
  ...groupRule.decorateOrderDoc(group[0].item, group[0].product),
385
- ...(orderType === "prize-draw"
386
- ? { prizeRevealDeadline: computePrizeRevealDeadline(group[0].product) }
387
- : {}),
388
370
  };
389
371
  // ── 15-minute payment window + seller UPI resolution (Tier PP) ──────────
390
372
  // Deadline + displayed UPI apply only to methods with a buyer-uploaded
@@ -577,7 +559,6 @@ export async function createCheckoutOrderAction(input) {
577
559
  shippingAddress = formatShippingAddress(resolvedAddress);
578
560
  }
579
561
  const db = getAdminDb();
580
- const otpRef = isDigitalCart ? null : consentOtpRef(db, uid, addressId);
581
562
  // SB6-C — pre-tx fetch products so we can run the maxPerUser cap check
582
563
  // (count queries can't run inside a Firestore transaction). The same
583
564
  // product docs are re-read inside the transaction below for stock + prize
@@ -613,21 +594,6 @@ export async function createCheckoutOrderAction(input) {
613
594
  let stockResult;
614
595
  try {
615
596
  stockResult = await db.runTransaction(async (tx) => {
616
- let emailOtpUsed = false;
617
- if (!isDigitalCart && otpRef) {
618
- const otpSnap = await tx.get(otpRef);
619
- const otpData = otpSnap.exists
620
- ? otpSnap.data()
621
- : null;
622
- const isConsentValid = otpData?.verified === true &&
623
- otpData.expiresAt &&
624
- (resolveDate(otpData.expiresAt)?.getTime() ?? 0) > Date.now();
625
- if (!isConsentValid) {
626
- const reason = !otpData ? "otp_not_verified" : "consent_expired";
627
- throw Object.assign(new ApiError(403, "Order verification required. Please complete OTP verification before placing this order."), { _failReason: reason });
628
- }
629
- emailOtpUsed = otpData.verifiedVia !== "sms" && otpData.verifiedVia !== "admin_bypass";
630
- }
631
597
  // SB-UNI-5 2026-05-13 — bundle-aware stock fan-out. Build the unique
632
598
  // product-id set across the whole cart (regular items + each bundle's
633
599
  // members) and fetch each one ONCE. Validation walks per cart-line and
@@ -662,7 +628,7 @@ export async function createCheckoutOrderAction(input) {
662
628
  // S-SBUNI-RULES 2026-05-13 — sync preflight (prize-pool cap, pre-order
663
629
  // quota) via rule registry. Bundles skip — they bypass the cart path.
664
630
  if (!item.bundleProductIds?.length) {
665
- runSyncPreflight([{ item, product }]);
631
+ runSyncPreflight([{ item, product }], paymentMethod);
666
632
  }
667
633
  availableItems.push({ item, product });
668
634
  }
@@ -687,29 +653,21 @@ export async function createCheckoutOrderAction(input) {
687
653
  selectedItemIds: null,
688
654
  updatedAt: new Date(),
689
655
  }, { merge: true });
690
- if (otpRef) {
691
- tx.delete(otpRef);
692
- }
693
656
  }
694
- return { available: availableItems, unavailable: unavailableItems, emailOtpUsed };
657
+ return { available: availableItems, unavailable: unavailableItems };
695
658
  });
696
659
  }
697
660
  catch (err) {
698
661
  void normalizeError(err);
699
- const reason = err?._failReason === "consent_expired"
700
- ? "consent_expired"
701
- : err?._failReason === "otp_not_verified"
702
- ? "otp_not_verified"
703
- : "unknown";
704
662
  failedCheckoutRepository
705
- .logCheckout(uid, reason, err instanceof Error ? err.message : String(err), {
663
+ .logCheckout(uid, "unknown", err instanceof Error ? err.message : String(err), {
706
664
  addressId,
707
665
  paymentMethod,
708
666
  })
709
667
  .catch((logErr) => { void normalizeError(logErr); serverLogger.warn(AUDIT_LOG_FAIL_MSG, { error: logErr instanceof Error ? logErr.message : String(logErr) }); });
710
668
  throw err;
711
669
  }
712
- const { available, unavailable, emailOtpUsed } = stockResult;
670
+ const { available, unavailable } = stockResult;
713
671
  if (available.length === 0) {
714
672
  failedCheckoutRepository
715
673
  .logCheckout(uid, "stock_failed", "All items out of stock", {
@@ -764,7 +722,6 @@ export async function createCheckoutOrderAction(input) {
764
722
  total += await createOrderForGroup(group, orderType, groupCtx);
765
723
  }
766
724
  await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
767
- grantConsentOtpBypassCredit(db, uid, emailOtpUsed, unavailable.length);
768
725
  dispatchOrderConfirmationEmails(emailsToSend);
769
726
  serverLogger.info(`createCheckoutOrderAction: ${orderIds.length} order(s) placed for uid=${uid}` +
770
727
  (unavailable.length > 0 ? ` (${unavailable.length} item(s) unavailable)` : ""));
@@ -993,9 +950,6 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
993
950
  const groupRuleRzp = getListingRule(lt0Rzp);
994
951
  const extraOrderFields = {
995
952
  ...groupRuleRzp.decorateOrderDoc(group[0].item, group[0].product),
996
- ...(orderType === "prize-draw" && group[0].product
997
- ? { prizeRevealDeadline: computePrizeRevealDeadline(group[0].product) }
998
- : {}),
999
953
  };
1000
954
  const order = await unitOfWork.orders.create({
1001
955
  productId: firstItem.productId,
@@ -1127,28 +1081,6 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1127
1081
  }
1128
1082
  shippingAddress = formatShippingAddress(resolvedAddressRzp);
1129
1083
  }
1130
- const db = getAdminDb();
1131
- if (!isDigitalCartRazorpay && addressId) {
1132
- const otpRef = consentOtpRef(db, uid, addressId);
1133
- const otpSnap = await otpRef.get();
1134
- const otpData = otpSnap.exists
1135
- ? otpSnap.data()
1136
- : null;
1137
- const isConsentValid = otpData?.verified === true &&
1138
- otpData.expiresAt &&
1139
- (resolveDate(otpData.expiresAt)?.getTime() ?? 0) > Date.now();
1140
- if (!isConsentValid) {
1141
- const reason = !otpData ? "otp_not_verified" : "consent_expired";
1142
- failedCheckoutRepository
1143
- .logPayment(uid, reason, "Consent OTP missing or expired at payment verify time", {
1144
- gatewayOrderId: razorpay_order_id,
1145
- gatewayPaymentId: razorpay_payment_id,
1146
- addressId,
1147
- })
1148
- .catch((err) => { void normalizeError(err); serverLogger.warn(AUDIT_LOG_FAIL_MSG, { error: err instanceof Error ? err.message : String(err) }); });
1149
- throw new ApiError(403, "Order verification required. Please complete OTP verification and retry.");
1150
- }
1151
- }
1152
1084
  // SB-UNI-5 2026-05-13 — bundle-aware product fetch + validation. Each
1153
1085
  // cart line gets paired with a "representative" product (first member id
1154
1086
  // for bundles, productId for regular items). The full bundle-member
@@ -1179,7 +1111,8 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1179
1111
  // S-SBUNI-RULES 2026-05-13 — sync preflight via rule registry.
1180
1112
  // Bundle items bypass the cart flow so skip the prize-pool cap.
1181
1113
  const preflightPairs = productChecks.filter((p) => p.product !== null && p.product !== undefined && !p.item.bundleProductIds?.length);
1182
- runSyncPreflight(preflightPairs);
1114
+ // This whole action is the Razorpay path — paymentMethod is always "online".
1115
+ runSyncPreflight(preflightPairs, "online");
1183
1116
  // SB-UNI-O 2026-05-15 — Live-item jurisdiction guard.
1184
1117
  if (!isDigitalCartRazorpay && resolvedAddressRzp) {
1185
1118
  assertLiveJurisdiction(cart.items, productByIdPaid, resolvedAddressRzp.state);
@@ -1310,10 +1243,6 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
1310
1243
  selectedItemIds: null,
1311
1244
  });
1312
1245
  });
1313
- if (!isDigitalCartRazorpay && addressId) {
1314
- const otpRefForDelete = consentOtpRef(db, uid, addressId);
1315
- safeFireAndForget(otpRefForDelete.delete(), "checkout: delete OTP consent ref after Razorpay payment");
1316
- }
1317
1246
  // outOfStockPolicy: "skip_items" — auto-refund the dropped items' value.
1318
1247
  // Razorpay already captured payment for the FULL cart before this stock
1319
1248
  // check ran, so anything not placed as an order must be given back.
@@ -1,10 +1,12 @@
1
1
  /**
2
- * maxPerUser gate + prize reveal deadline helper for the checkout pipeline
3
- * (SB6-C, SB8-A).
2
+ * maxPerUser gate for the checkout pipeline (SB6-C).
4
3
  *
5
4
  * `enforcePrizePoolCap` was removed in S-SBUNI-RULES (2026-05-13) — the
6
5
  * equivalent check now lives in `prizeDrawRule.preflightChecks` in the rule
7
- * registry and is invoked via `runSyncPreflight`.
6
+ * registry and is invoked via `runSyncPreflight`. `computePrizeRevealDeadline`
7
+ * (SB8-A) was removed with the buyer-claim-deadline mechanic — reveal is now
8
+ * fully automatic, there's nothing left for a buyer to "claim" before a
9
+ * deadline.
8
10
  */
9
11
  import type { ProductDocument } from "../../../../features/products/schemas/firestore";
10
12
  import type { CartItemDocument } from "../../../../features/cart/schemas/firestore";
@@ -34,16 +36,3 @@ export declare function enforceMaxPerUserForCart(args: {
34
36
  product: ProductDocument;
35
37
  }>;
36
38
  }): Promise<void>;
37
- /**
38
- * SB8-A — compute the deadline by which the buyer must claim their prize
39
- * via the reveal API.
40
- *
41
- * Rules:
42
- * - If the reveal window has not yet opened:
43
- * deadline = min(revealWindowStart + revealDeadlineDays, revealWindowEnd)
44
- * - If the window is already open:
45
- * deadline = min(now + revealDeadlineDays, revealWindowEnd)
46
- *
47
- * Returns `undefined` if the product lacks the prize-draw fields.
48
- */
49
- export declare function computePrizeRevealDeadline(product: Pick<ProductDocument, "prizeRevealWindowStart" | "prizeRevealWindowEnd" | "prizeRevealDeadlineDays">, now?: Date): Date | undefined;
@@ -1,10 +1,12 @@
1
1
  /**
2
- * maxPerUser gate + prize reveal deadline helper for the checkout pipeline
3
- * (SB6-C, SB8-A).
2
+ * maxPerUser gate for the checkout pipeline (SB6-C).
4
3
  *
5
4
  * `enforcePrizePoolCap` was removed in S-SBUNI-RULES (2026-05-13) — the
6
5
  * equivalent check now lives in `prizeDrawRule.preflightChecks` in the rule
7
- * registry and is invoked via `runSyncPreflight`.
6
+ * registry and is invoked via `runSyncPreflight`. `computePrizeRevealDeadline`
7
+ * (SB8-A) was removed with the buyer-claim-deadline mechanic — reveal is now
8
+ * fully automatic, there's nothing left for a buyer to "claim" before a
9
+ * deadline.
8
10
  */
9
11
  import { ValidationError } from "../../../../errors";
10
12
  import { orderRepository } from "../../../../repositories";
@@ -54,30 +56,3 @@ export async function enforceMaxPerUserForCart(args) {
54
56
  throw Object.assign(new ValidationError(`Purchase limit reached for one or more items. ${summary}`), { code: "MAX_PER_USER", violations });
55
57
  }
56
58
  }
57
- /**
58
- * SB8-A — compute the deadline by which the buyer must claim their prize
59
- * via the reveal API.
60
- *
61
- * Rules:
62
- * - If the reveal window has not yet opened:
63
- * deadline = min(revealWindowStart + revealDeadlineDays, revealWindowEnd)
64
- * - If the window is already open:
65
- * deadline = min(now + revealDeadlineDays, revealWindowEnd)
66
- *
67
- * Returns `undefined` if the product lacks the prize-draw fields.
68
- */
69
- export function computePrizeRevealDeadline(product, now = new Date()) {
70
- const windowStart = product.prizeRevealWindowStart
71
- ? new Date(product.prizeRevealWindowStart)
72
- : undefined;
73
- const windowEnd = product.prizeRevealWindowEnd
74
- ? new Date(product.prizeRevealWindowEnd)
75
- : undefined;
76
- if (!windowStart || !windowEnd)
77
- return undefined;
78
- const days = product.prizeRevealDeadlineDays ?? 3;
79
- const dayMs = 86400000;
80
- const base = windowStart.getTime() > now.getTime() ? windowStart : now;
81
- const candidate = new Date(base.getTime() + days * dayMs);
82
- return candidate.getTime() < windowEnd.getTime() ? candidate : windowEnd;
83
- }
@@ -13,12 +13,6 @@ export function orderDocumentToOrder(doc) {
13
13
  ...(item.prizeRevealStatus
14
14
  ? { prizeRevealStatus: item.prizeRevealStatus }
15
15
  : {}),
16
- ...(item.prizeRevealDeadline
17
- ? { prizeRevealDeadline: item.prizeRevealDeadline }
18
- : {}),
19
- ...(item.revealedItemNumber != null
20
- ? { revealedItemNumber: item.revealedItemNumber }
21
- : {}),
22
16
  ...(item.cancelledQuantity != null
23
17
  ? { cancelledQuantity: item.cancelledQuantity }
24
18
  : {}),
@@ -80,14 +74,7 @@ export function orderDocumentToOrder(doc) {
80
74
  },
81
75
  }
82
76
  : {}),
83
- ...(doc.prizeRevealDeadline
84
- ? {
85
- prizeRevealDeadline: doc.prizeRevealDeadline instanceof Date
86
- ? doc.prizeRevealDeadline.toISOString()
87
- : String(doc.prizeRevealDeadline),
88
- }
89
- : {}),
90
- ...(doc.prizeRevealExpired !== undefined ? { prizeRevealExpired: doc.prizeRevealExpired } : {}),
91
77
  ...(doc.prizeDrawProductId ? { prizeDrawProductId: doc.prizeDrawProductId } : {}),
78
+ ...(doc.prizeRevealMode ? { prizeRevealMode: doc.prizeRevealMode } : {}),
92
79
  };
93
80
  }
@@ -4,7 +4,7 @@ import { productRepository } from "../../../../repositories";
4
4
  import { requireRoleUser } from "../../../../providers/auth-firebase/helpers";
5
5
  import { isAdminUser } from "../../../../features/auth/role-predicates";
6
6
  import { productInputSchema, productUpdateSchema, auctionInputSchema, preOrderInputSchema, setFeaturedSchema, setStatusSchema } from "../../../shared/features/products/schema";
7
- import { assertProductOwnership, assertStatusTransition } from "./service";
7
+ import { assertProductOwnership, assertStatusTransition, assertPrizeDrawNotLocked, assertPrizeDrawWonItemsImmutable, } from "./service";
8
8
  import { ValidationError } from "../../../shared/errors/index";
9
9
  export async function createProductAction(input) {
10
10
  return wrapAction(async () => {
@@ -54,17 +54,19 @@ export async function createPreOrderAction(input) {
54
54
  export async function updateProductAction(productId, input) {
55
55
  return wrapAction(async () => {
56
56
  const user = await requireRoleUser(["seller", "admin"]);
57
- await assertProductOwnership(productId, user.uid);
57
+ const product = await assertProductOwnership(productId, user.uid);
58
58
  const parsed = productUpdateSchema.safeParse(input);
59
59
  if (!parsed.success)
60
60
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
61
+ assertPrizeDrawWonItemsImmutable(product, parsed.data.prizeDrawItems);
61
62
  return productRepository.update(productId, parsed.data);
62
63
  });
63
64
  }
64
65
  export async function deleteProductAction(productId) {
65
66
  return wrapAction(async () => {
66
67
  const user = await requireRoleUser(["seller", "admin"]);
67
- await assertProductOwnership(productId, user.uid);
68
+ const product = await assertProductOwnership(productId, user.uid);
69
+ assertPrizeDrawNotLocked(product, "deleted");
68
70
  return productRepository.delete(productId);
69
71
  });
70
72
  }
@@ -76,6 +78,9 @@ export async function setProductStatusAction(input) {
76
78
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
77
79
  const product = await assertProductOwnership(parsed.data.productId, user.uid);
78
80
  assertStatusTransition(product.status, parsed.data.status);
81
+ if (parsed.data.status !== "published") {
82
+ assertPrizeDrawNotLocked(product, "unpublished or archived");
83
+ }
79
84
  // SB-UNI-O — live listings must be admin-verified before a seller can publish them.
80
85
  if (parsed.data.status === "published" &&
81
86
  (product.listingType ?? "standard") === "live" &&
@@ -3,6 +3,21 @@ import type { ProductDocument } from "../../../shared/features/products/types";
3
3
  export declare function assertProductOwnership(productId: string, sellerId: string): Promise<ProductDocument>;
4
4
  /** Validate that a status transition is allowed. */
5
5
  export declare function assertStatusTransition(from: string, to: string): void;
6
+ /**
7
+ * Anti-scam guard — once a prize draw has at least one paid entry, the
8
+ * seller can no longer take it down (unpublish/archive/delete). Prevents a
9
+ * seller from dodging shipment of an expensive won prize by pulling the
10
+ * listing after the fact.
11
+ */
12
+ export declare function assertPrizeDrawNotLocked(product: Pick<ProductDocument, "listingType" | "prizeCurrentEntries">, action: string): void;
13
+ /**
14
+ * Anti-scam guard — once a prize item has been won, its title/images/
15
+ * description/condition/estimatedValue become immutable server-side (the
16
+ * client editor already locks this visually via PrizeDrawItemsEditor's
17
+ * `locked` state, but that's UI-only — this is the enforcement). Prevents a
18
+ * seller from swapping in a cheaper item after a buyer has already won.
19
+ */
20
+ export declare function assertPrizeDrawWonItemsImmutable(currentProduct: Pick<ProductDocument, "prizeDrawItems">, input: unknown): void;
6
21
  /** Assert the product has available stock for a purchase. */
7
22
  export declare function assertInStock(product: ProductDocument, quantity?: number): void;
8
23
  /** Derive effective price (respects currentBid for auctions, base price otherwise). */
@@ -1,6 +1,7 @@
1
1
  import { productRepository } from "../../../../repositories";
2
2
  import { isAuctionListing, isPreOrderListing, } from "../../../../features/products/utils/listing-type";
3
3
  import { ProductNotFoundError, ProductOwnershipError, ProductStatusError, ProductStockError, } from "../../../shared/features/products/errors";
4
+ import { ValidationError } from "../../../shared/errors/index";
4
5
  const ALLOWED_STATUS_TRANSITIONS = {
5
6
  draft: ["published", "archived"],
6
7
  published: ["archived", "draft"],
@@ -25,6 +26,46 @@ export function assertStatusTransition(from, to) {
25
26
  throw new ProductStatusError(from, to);
26
27
  }
27
28
  }
29
+ /**
30
+ * Anti-scam guard — once a prize draw has at least one paid entry, the
31
+ * seller can no longer take it down (unpublish/archive/delete). Prevents a
32
+ * seller from dodging shipment of an expensive won prize by pulling the
33
+ * listing after the fact.
34
+ */
35
+ export function assertPrizeDrawNotLocked(product, action) {
36
+ if ((product.listingType ?? "standard") !== "prize-draw")
37
+ return;
38
+ if ((product.prizeCurrentEntries ?? 0) <= 0)
39
+ return;
40
+ throw new ValidationError(`This prize draw has active entries and cannot be ${action} — buyers have already purchased tickets. The draw must run its course.`);
41
+ }
42
+ /**
43
+ * Anti-scam guard — once a prize item has been won, its title/images/
44
+ * description/condition/estimatedValue become immutable server-side (the
45
+ * client editor already locks this visually via PrizeDrawItemsEditor's
46
+ * `locked` state, but that's UI-only — this is the enforcement). Prevents a
47
+ * seller from swapping in a cheaper item after a buyer has already won.
48
+ */
49
+ export function assertPrizeDrawWonItemsImmutable(currentProduct, input) {
50
+ if (!Array.isArray(input))
51
+ return;
52
+ const current = currentProduct.prizeDrawItems ?? [];
53
+ for (const currentItem of current) {
54
+ if (!currentItem.isWon)
55
+ continue;
56
+ const incoming = input.find((it) => it.itemNumber === currentItem.itemNumber);
57
+ if (!incoming)
58
+ continue;
59
+ const changed = incoming.title !== currentItem.title ||
60
+ incoming.description !== currentItem.description ||
61
+ incoming.condition !== currentItem.condition ||
62
+ incoming.estimatedValue !== currentItem.estimatedValue ||
63
+ JSON.stringify(incoming.images) !== JSON.stringify(currentItem.images);
64
+ if (changed) {
65
+ throw new ValidationError(`Prize #${currentItem.itemNumber} has already been won and can no longer be edited.`);
66
+ }
67
+ }
68
+ }
28
69
  /** Assert the product has available stock for a purchase. */
29
70
  export function assertInStock(product, quantity = 1) {
30
71
  if ((product.availableQuantity ?? 0) < quantity) {
@@ -15,6 +15,8 @@ export declare const onProductStockChange: import("./types").DocumentTriggerFunc
15
15
  }, {
16
16
  [x: string]: import("../../..").JsonValue;
17
17
  }>;
18
+ export declare const onPrizeDrawPaymentConfirmed: import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot, import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot>;
19
+ export declare const prizeDrawSoldOutReveal: import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>;
18
20
  export declare const onReviewWrite: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
19
21
  export declare const onCategoryWrite: import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>;
20
22
  export declare const onStoreWrite: import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>;
@@ -53,7 +55,7 @@ export declare const FIRESTORE_TRIGGER_FUNCTIONS: readonly [import("./types").Do
53
55
  [x: string]: import("../../..").JsonValue;
54
56
  }, {
55
57
  [x: string]: import("../../..").JsonValue;
56
- }>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<{
58
+ }>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot, import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<{
57
59
  [x: string]: import("../../..").JsonValue;
58
60
  }, {
59
61
  [x: string]: import("../../..").JsonValue;