@mohasinac/appkit 4.63.4 → 4.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/categories/adapters.js +13 -0
  4. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  5. package/dist/_internal/server/features/checkout/actions.js +22 -5
  6. package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
  7. package/dist/_internal/server/features/events/like-actions.js +67 -0
  8. package/dist/_internal/server/features/events/likes.d.ts +65 -0
  9. package/dist/_internal/server/features/events/likes.js +146 -0
  10. package/dist/_internal/server/features/orders/adapters.js +3 -1
  11. package/dist/_internal/server/features/seo/index.d.ts +3 -0
  12. package/dist/_internal/server/features/seo/index.js +2 -0
  13. package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
  14. package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
  15. package/dist/_internal/server/features/seo/robots.js +20 -1
  16. package/dist/_internal/server/features/seo/sitemap.js +125 -27
  17. package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
  18. package/dist/_internal/server/features/seo/static-params.js +228 -0
  19. package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
  20. package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
  21. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  22. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  23. package/dist/_internal/server/features/stores/adapters.js +88 -1
  24. package/dist/_internal/server/functions/scheduled.d.ts +16 -1
  25. package/dist/_internal/server/functions/scheduled.js +23 -1
  26. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  27. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  28. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  29. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  30. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  31. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  32. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  33. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  34. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  35. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  36. package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
  37. package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
  38. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  39. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  40. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  41. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  42. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
  43. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
  44. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  45. package/dist/_internal/server/notifications/channel-health.js +2 -2
  46. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  47. package/dist/_internal/server/notifications/send-guard.js +4 -2
  48. package/dist/_internal/shared/actions/action-registry.js +89 -0
  49. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  50. package/dist/_internal/shared/checkout/order-math.js +1 -1
  51. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  52. package/dist/_internal/shared/features/checkout/config.js +2 -0
  53. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  54. package/dist/_internal/shared/features/messaging/config.js +4 -1
  55. package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
  56. package/dist/_internal/shared/features/seller-standing/config.js +153 -0
  57. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  58. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  59. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  60. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  61. package/dist/_internal/shared/fees/calculator.js +7 -0
  62. package/dist/cli/index.js +2 -0
  63. package/dist/client.d.ts +2 -2
  64. package/dist/client.js +1 -1
  65. package/dist/constants/api-endpoints.d.ts +39 -0
  66. package/dist/constants/api-endpoints.js +16 -0
  67. package/dist/constants/notification-types.d.ts +1 -1
  68. package/dist/constants/notification-types.js +1 -1
  69. package/dist/core/integration-keys.d.ts +5 -0
  70. package/dist/core/integration-keys.js +10 -0
  71. package/dist/features/about/components/FeesView.js +6 -0
  72. package/dist/features/about/components/HelpPageView.js +9 -1
  73. package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
  74. package/dist/features/account/components/BecomeSellerView.js +2 -2
  75. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  76. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  77. package/dist/features/account/types/index.d.ts +2 -0
  78. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  79. package/dist/features/admin/actions/notification-actions.js +154 -9
  80. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  81. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  82. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  83. package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
  84. package/dist/features/admin/components/AdminStoreEditorView.js +31 -2
  85. package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
  86. package/dist/features/admin/components/AdminStoresView.js +59 -2
  87. package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
  88. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  89. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  90. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  91. package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
  92. package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
  93. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  94. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  95. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  96. package/dist/features/admin/schemas/firestore.js +8 -3
  97. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  98. package/dist/features/auth/checkout-value-otp.js +1 -1
  99. package/dist/features/auth/permissions/constants.d.ts +2 -2
  100. package/dist/features/auth/permissions/constants.js +2 -1
  101. package/dist/features/blog/components/BlogIndexPageView.js +2 -2
  102. package/dist/features/blog/components/BlogListView.js +2 -1
  103. package/dist/features/blog/components/BlogPostView.js +2 -1
  104. package/dist/features/blog/constants/category-badge.d.ts +33 -0
  105. package/dist/features/blog/constants/category-badge.js +40 -0
  106. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  107. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  108. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  109. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  110. package/dist/features/cart/repository/cart.repository.js +2 -1
  111. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  112. package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
  113. package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
  114. package/dist/features/categories/schemas/firestore.d.ts +25 -0
  115. package/dist/features/categories/utils/load-category-options.d.ts +33 -0
  116. package/dist/features/categories/utils/load-category-options.js +50 -0
  117. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  118. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  119. package/dist/features/events/components/EventCard.js +4 -12
  120. package/dist/features/events/components/EventInsights.d.ts +27 -0
  121. package/dist/features/events/components/EventInsights.js +10 -0
  122. package/dist/features/events/components/EventLikeButton.d.ts +21 -0
  123. package/dist/features/events/components/EventLikeButton.js +59 -0
  124. package/dist/features/events/components/EventLikersRail.d.ts +23 -0
  125. package/dist/features/events/components/EventLikersRail.js +54 -0
  126. package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
  127. package/dist/features/events/components/EventTypeBadge.js +7 -0
  128. package/dist/features/events/components/EventsListPageView.js +2 -2
  129. package/dist/features/events/components/SpinWheel.d.ts +41 -0
  130. package/dist/features/events/components/SpinWheel.js +128 -0
  131. package/dist/features/events/components/SpinWheelView.js +36 -15
  132. package/dist/features/events/constants/event-type-meta.d.ts +54 -0
  133. package/dist/features/events/constants/event-type-meta.js +109 -0
  134. package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
  135. package/dist/features/events/repository/event-likes.repository.js +151 -0
  136. package/dist/features/events/schemas/firestore.d.ts +111 -0
  137. package/dist/features/events/schemas/likes.d.ts +81 -0
  138. package/dist/features/events/schemas/likes.js +59 -0
  139. package/dist/features/events/types/index.d.ts +7 -0
  140. package/dist/features/events/utils/carousel-slide.d.ts +42 -0
  141. package/dist/features/events/utils/carousel-slide.js +174 -0
  142. package/dist/features/events/utils/event-charts.d.ts +65 -0
  143. package/dist/features/events/utils/event-charts.js +172 -0
  144. package/dist/features/faq/constants/help-guides.js +9 -0
  145. package/dist/features/faq/schemas/firestore.js +1 -0
  146. package/dist/features/faq/types/index.d.ts +19 -0
  147. package/dist/features/homepage/components/BrandsSection.js +25 -1
  148. package/dist/features/homepage/components/HeroCarousel.js +47 -10
  149. package/dist/features/homepage/lib/section-renderer.js +1 -1
  150. package/dist/features/homepage/types/index.d.ts +7 -0
  151. package/dist/features/orders/actions/refund-actions.js +1 -0
  152. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  153. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  154. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  155. package/dist/features/orders/repository/orders.repository.js +2 -0
  156. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  157. package/dist/features/orders/types/index.d.ts +5 -2
  158. package/dist/features/search/action-index/settings-entries.js +9 -0
  159. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  160. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  161. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  162. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  163. package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
  164. package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
  165. package/dist/features/stores/components/SellerStandingPanel.js +25 -0
  166. package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
  167. package/dist/features/stores/components/SellerTierBadge.js +19 -0
  168. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +33 -2
  169. package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
  170. package/dist/features/stores/components/StoreCreateForm.js +107 -0
  171. package/dist/features/stores/components/StoreGuideHubView.js +18 -5
  172. package/dist/features/stores/components/StoreHeader.js +2 -1
  173. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  174. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  175. package/dist/features/stores/repository/store.repository.js +52 -1
  176. package/dist/features/stores/schemas/firestore.d.ts +76 -7
  177. package/dist/features/stores/schemas/firestore.js +21 -6
  178. package/dist/features/stores/schemas/index.d.ts +88 -2
  179. package/dist/features/stores/schemas/index.js +14 -0
  180. package/dist/features/stores/schemas/store-create.d.ts +47 -0
  181. package/dist/features/stores/schemas/store-create.js +86 -0
  182. package/dist/features/stores/types/index.d.ts +42 -0
  183. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  184. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  185. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  186. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  187. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  188. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  189. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  190. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  191. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  192. package/dist/features/whatsapp-bot/components/index.js +2 -0
  193. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  194. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  195. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  196. package/dist/features/whatsapp-bot/index.js +3 -0
  197. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  198. package/dist/features/whatsapp-bot/manifest.js +13 -0
  199. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  200. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  201. package/dist/features/whatsapp-bot/server.js +7 -0
  202. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  203. package/dist/features/whatsapp-bot/types/index.js +1 -0
  204. package/dist/index.d.ts +23 -6
  205. package/dist/index.js +38 -12
  206. package/dist/next/routing/route-map.d.ts +6 -1
  207. package/dist/next/routing/route-map.js +14 -0
  208. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  209. package/dist/providers/db-firebase/base.repository.js +1 -1
  210. package/dist/repositories/index.d.ts +1 -1
  211. package/dist/repositories/index.js +1 -1
  212. package/dist/schemas/registry.d.ts +115 -0
  213. package/dist/schemas/registry.js +2 -1
  214. package/dist/security/pii-encrypt.js +1 -1
  215. package/dist/server.d.ts +15 -2
  216. package/dist/server.js +32 -2
  217. package/dist/styles.css +36 -1
  218. package/dist/tailwind-utilities.css +1 -1
  219. package/dist/ui/components/HorizontalScroller.js +15 -2
  220. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  221. package/dist/ui/components/NavigationLoader.js +79 -30
  222. package/dist/ui/components/PageHeroHeading.d.ts +40 -0
  223. package/dist/ui/components/PageHeroHeading.js +15 -0
  224. package/dist/ui/components/Typography.d.ts +3 -3
  225. package/dist/ui/components/Typography.js +59 -31
  226. package/dist/ui/components/Typography.style.css +35 -0
  227. package/dist/ui/index.d.ts +2 -0
  228. package/dist/ui/index.js +1 -0
  229. package/package.json +1 -1
  230. package/scripts/seed-cli.mjs +4 -2
  231. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  232. package/dist/features/orders/utils/order-announcement-message.js +0 -16
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Server-side link lists for the taxonomy hub pages.
3
+ *
4
+ * Feeds `<TaxonomyLinkIndex>`, which renders them as real `<a href>`s in the
5
+ * server HTML. See that component for why: measured 2026-10-24, `/categories`,
6
+ * `/brands` and `/parts` each emitted ZERO outbound entity links because all
7
+ * three filter client-side, so a crawler landing on a hub had nothing to
8
+ * follow and the franchise detail pages were reachable by no link at all.
9
+ *
10
+ * 🛑 Every function FAILS OPEN with `[]`. These pages are statically rendered;
11
+ * a Firestore hiccup at build time must degrade to "no index section" rather
12
+ * than fail the build or 500 the page. An empty list makes
13
+ * `<TaxonomyLinkIndex>` render nothing at all, which is the correct fallback —
14
+ * the interactive grid above it is unaffected either way.
15
+ *
16
+ * 🛑 A read here does NOT make the hub dynamic. Only `searchParams`,
17
+ * `cookies()` and `headers()` do (Root Cause #94); a Server Component awaiting
18
+ * Firestore stays static and simply bakes the result in at build/ISR time.
19
+ */
20
+ import { normalizeError } from "../../../../errors/normalize.js";
21
+ import { serverLogger } from "../../../../monitoring/server-logger.js";
22
+ import { ROUTES } from "../../../../next/routing/route-map.js";
23
+ import { categoriesRepository } from "../../../../features/categories/repository/categories.repository.js";
24
+ /** Alphabetical, so the list reads as an index rather than an arbitrary dump. */
25
+ function toLinks(rows, href) {
26
+ return rows
27
+ .filter((row) => Boolean(row.slug) && Boolean(row.name))
28
+ .map((row) => ({ href: href(row.slug), label: row.name }))
29
+ .sort((a, b) => a.label.localeCompare(b.label));
30
+ }
31
+ async function safeLinks(label, load) {
32
+ try {
33
+ return await load();
34
+ }
35
+ catch (err) {
36
+ void normalizeError(err);
37
+ serverLogger.error(`taxonomy link index "${label}" failed and rendered 0 links`, {
38
+ section: label,
39
+ error: err,
40
+ });
41
+ return [];
42
+ }
43
+ }
44
+ /** Every active brand — 28 of them, and none was linked from `/brands`. */
45
+ export function brandLinks() {
46
+ return safeLinks("brand", async () => toLinks(await categoriesRepository.findActiveBrands(), (slug) => String(ROUTES.PUBLIC.BRAND_DETAIL(slug))));
47
+ }
48
+ /**
49
+ * Active franchises only.
50
+ *
51
+ * 7 of the 8 are deliberately `isActive: false` because an unstocked facet
52
+ * "renders as a card in the grid that opens an empty page" (`brand-rows.ts`
53
+ * trap 3), so this list is short on purpose. Linking the inactive ones would
54
+ * re-create exactly the empty-page problem the rest of this work removes.
55
+ */
56
+ export function franchiseLinks() {
57
+ return safeLinks("franchise", async () => toLinks(await categoriesRepository.findActiveFranchises(), (slug) => String(ROUTES.PUBLIC.FRANCHISE_DETAIL(slug))));
58
+ }
59
+ /**
60
+ * The tier-0 category roots — the six real entry points a visitor drills down
61
+ * from, not the 4,698-row forest.
62
+ *
63
+ * `CategoryDetailPageView` already renders a root as a tier-grouped index of
64
+ * its descendants, so a root is where a crawl of the taxonomy should start.
65
+ * Listing every leaf here would put the 4,658 empty pages back in front of
66
+ * Google by another route, which is the thing being undone.
67
+ */
68
+ export function categoryRootLinks() {
69
+ return safeLinks("category-root", async () => {
70
+ const roots = await categoriesRepository.getRootCategories();
71
+ /*
72
+ * 🛑 `getRootCategories()` filters only on the DEPRECATED `isBrand` flag,
73
+ * and brands, franchises and bundles are all tier-0 by design — they sit
74
+ * outside the forest's numbering and are pointed AT rather than lived
75
+ * under. A franchise row carries no `isBrand`, so it survives that filter
76
+ * and would appear in a list captioned "categories".
77
+ *
78
+ * Rejecting any row that carries a `categoryType` is the same predicate
79
+ * the /categories tab uses ("categoryType empty AND tier === 0"), and it
80
+ * is the one that stays correct when a fifth discriminator is added.
81
+ */
82
+ const plain = roots.filter((row) => !row.categoryType);
83
+ return toLinks(plain, (slug) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(slug)));
84
+ });
85
+ }
@@ -7,7 +7,7 @@ export const PUBLIC_SITE_SETTINGS_FIELDS = [
7
7
  "contact", // user/orders/[id]/payment — contact.whatsappNumber (narrowed: no address/upiVpa)
8
8
  "payment", // CheckoutRouteClient — payment.otpCheckoutThreshold (narrowed)
9
9
  "listings", // useListingTypeFlags — narrowed to listings.listingTypes; categoryTypes has no client reader
10
- "notificationChannels", // NotificationPreferencesPanel reads the per-channel toggles
10
+ "notificationChannels", // NARROWED: only {email,whatsapp,sms}.enabled — see the projection below
11
11
  "announcementBar", // the homepage banner (AdminSiteSettingsView edits it)
12
12
  /*
13
13
  * `support.enabled` — a REAL client reader, which is the bar this list sets:
@@ -138,7 +138,25 @@ export function toPublicSiteSettings(doc, opts) {
138
138
  listings: {
139
139
  listingTypes: doc.listings?.listingTypes,
140
140
  },
141
- notificationChannels: doc.notificationChannels,
141
+ /*
142
+ * 🛑 Narrowed to the three booleans a client reads, not the group by
143
+ * reference. Emitted whole, this published every WhatsApp channel setting
144
+ * to anonymous callers — `minPriority`, and the `types[]` allow-list naming
145
+ * exactly which notification types each channel carries. Nothing reads
146
+ * those in a browser: `NotificationPreferencesPanel` wants the three
147
+ * `enabled` flags to label its toggles, `whatsapp.otpEnabled` is read by a
148
+ * SERVER action, and the admin editor reads `GET /api/admin/site`.
149
+ *
150
+ * Same reasoning as `support` below — a field added to a channel later must
151
+ * not be published by the act of adding it (Root Cause #70).
152
+ */
153
+ notificationChannels: doc.notificationChannels
154
+ ? {
155
+ email: { enabled: doc.notificationChannels.email?.enabled === true },
156
+ whatsapp: { enabled: doc.notificationChannels.whatsapp?.enabled === true },
157
+ sms: { enabled: doc.notificationChannels.sms?.enabled === true },
158
+ }
159
+ : undefined,
142
160
  announcementBar: doc.announcementBar,
143
161
  // Narrowed to the one boolean a client reads, not the group by reference —
144
162
  // so a field added to `support` later is not published by accident.
@@ -25,9 +25,9 @@ import type { StoreDetail, StoreListItem } from "../../../../features/stores/typ
25
25
  * Document fields that may reach an unauthenticated client. Keep in sync with
26
26
  * the object literals below — the audit asserts both directions.
27
27
  */
28
- export declare const PUBLIC_STORE_FIELDS: readonly ["id", "storeSlug", "ownerId", "storeName", "storeDescription", "storeCategory", "storeLogoURL", "storeBannerURL", "status", "isPublic", "stats", "bio", "location", "website", "socialLinks", "returnPolicy", "shippingPolicy", "isVacationMode", "vacationMessage", "supportsShippingProtection", "supportsSaleProtection", "allowSharedPurchase", "sharedPurchaseHandlingFee", "sharedPurchaseExtraShippingPerDestination", "createdAt"];
28
+ export declare const PUBLIC_STORE_FIELDS: readonly ["id", "storeSlug", "ownerId", "storeName", "storeDescription", "storeCategory", "storeLogoURL", "storeBannerURL", "status", "isPublic", "stats", "bio", "location", "website", "socialLinks", "returnPolicy", "shippingPolicy", "isVacationMode", "vacationMessage", "supportsShippingProtection", "supportsSaleProtection", "allowSharedPurchase", "sharedPurchaseHandlingFee", "sharedPurchaseExtraShippingPerDestination", "isVerified", "sellerTier", "standing", "createdAt"];
29
29
  /** Everything else, with the reason it stays server-side. */
30
- export declare const PRIVATE_STORE_FIELDS: readonly ["trustedSeller", "acceptsPoints", "searchTxt", "updatedAt", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "pricesIncludeGst", "shippingPricing", "returnsDirectToSeller", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt", "gstin", "legalName", "registeredAddress"];
30
+ export declare const PRIVATE_STORE_FIELDS: readonly ["whatsappNotifySubscribed", "trustedSeller", "acceptsPoints", "searchTxt", "whatsappNotifySubscribed", "updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "tierLockedByAdmin", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "pricesIncludeGst", "shippingPricing", "returnsDirectToSeller", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt", "gstin", "legalName", "registeredAddress"];
31
31
  /**
32
32
  * Accepted input: a `StoreDocument` (the common case) or a loosely-typed
33
33
  * Firestore row. The legacy top-level counters below predate `stats` and are
@@ -49,10 +49,61 @@ export const PUBLIC_STORE_FIELDS = [
49
49
  "allowSharedPurchase",
50
50
  "sharedPurchaseHandlingFee",
51
51
  "sharedPurchaseExtraShippingPerDestination",
52
+ /*
53
+ * ── Seller trust, 2026-10-24 ──────────────────────────────────────────────
54
+ *
55
+ * 🛑 `isVerified` MOVED here from PRIVATE, deliberately, and this is a
56
+ * reclassification rather than a drive-by.
57
+ *
58
+ * It was filed as "admin moderation state; surfaced via a separate badge
59
+ * feed if ever needed". That badge feed never existed, and meanwhile three
60
+ * things were already treating the field as public: `/sellers` filters on
61
+ * `isVerified == true` to offer a "verified sellers" view,
62
+ * `StoreCapabilitiesGuideView` told sellers in as many words that "the
63
+ * Verified badge appears on your store profile page and on every listing
64
+ * card", and the seller onboarding checklist offered "Request verified
65
+ * badge" as a step. All three described a public badge; the projection
66
+ * stripped the field, so no badge could render anywhere and the guide copy
67
+ * was simply false.
68
+ *
69
+ * It is a badge, not moderation state — `status` / `isPublic` /
70
+ * `suspensionReason` are the moderation fields and all three stay private
71
+ * or already are public for their own reasons. The named reader is
72
+ * `<SellerTierBadge>`.
73
+ *
74
+ * `sellerTier` and `standing` are public for the same reason: the tier is
75
+ * the whole point of the feature being visible to a buyer, and `standing`
76
+ * carries the METRICS behind it (counts and a mean — no buyer uids, no
77
+ * addresses) so a seller's own dashboard panel can render progress from the
78
+ * store document it already holds without a second read.
79
+ *
80
+ * 🛑 `trustedSeller` stays PRIVATE and must never be added here. It is the
81
+ * money-moving half of the same decision — see its entry below.
82
+ */
83
+ "isVerified",
84
+ "sellerTier",
85
+ "standing",
52
86
  "createdAt",
53
87
  ];
54
88
  /** Everything else, with the reason it stays server-side. */
55
89
  export const PRIVATE_STORE_FIELDS = [
90
+ /*
91
+ * 🛑 `whatsappNotifySubscribed` — a BILLING-BEARING operational flag.
92
+ *
93
+ * It records whether this seller opted in to the platform WhatsApp number,
94
+ * and the gate fails CLOSED on it: absent means not subscribed, because
95
+ * defaulting a missing flag to true would message customers on behalf of
96
+ * every seller who never signed up and bill the platform per template.
97
+ *
98
+ * A buyer has no use for it — it says nothing about the goods, the price
99
+ * or the dispatch record. Published, it would also advertise which sellers
100
+ * can be reached on WhatsApp, which is a channel the platform owns rather
101
+ * than the seller, and an invitation to contact them off-platform.
102
+ *
103
+ * Default-private per this file's own rule: a field is private until
104
+ * someone can name the client that reads it.
105
+ */
106
+ "whatsappNotifySubscribed",
56
107
  /*
57
108
  * 🛑 `trustedSeller` — OUR RISK POSTURE, not a badge.
58
109
  *
@@ -94,14 +145,32 @@ export const PRIVATE_STORE_FIELDS = [
94
145
  // it has no client reader and would bloat every store payload with a few
95
146
  // hundred tokens.
96
147
  "searchTxt",
148
+ /*
149
+ * A BILLING RELATIONSHIP between the seller and the platform: whether this
150
+ * store pays the monthly add-on that sends its buyers WhatsApp order
151
+ * updates. The buyer learns about it where it is actually decided — the
152
+ * per-store add-on picker at checkout, which reads the pricing preview, not
153
+ * this projection. Publishing it here would expose which sellers pay us for
154
+ * what, which is commercial terms (same reason `customCommissionRate` is
155
+ * below), and it has no reader on any public store surface.
156
+ */
157
+ "whatsappNotifySubscribed",
97
158
  "updatedAt", // bookkeeping; no public reader
159
+ "whatsappConfig", // Meta access token + WABA/catalog ids — secret
98
160
  "adminNotes", // schema comment: never shown to the owner or the public
99
161
  // Admin decisions with actor UIDs and suspension reasons. The store OWNER
100
162
  // sees this on their own admin surfaces; an anonymous visitor to /stores has
101
163
  // no business reading who suspended whom and why.
102
164
  "statusHistory",
103
165
  "statusHistoryTruncated",
104
- "isVerified", // admin moderation state; surfaced via a separate badge feed if ever needed
166
+ // `isVerified` moved to PUBLIC_STORE_FIELDS on 2026-10-24 — it is a badge,
167
+ // and three surfaces already described it as one. See the note there.
168
+ //
169
+ // `tierLockedByAdmin` is the opposite call: `sellerTier` is public because a
170
+ // buyer should see the badge, but HOW we administer that record — whether an
171
+ // admin pinned it against the monthly recompute — says nothing about the
172
+ // goods or the seller and is nobody's business outside the back office.
173
+ "tierLockedByAdmin",
105
174
  "suspensionReason", // admin moderation note
106
175
  "capabilities", // internal feature gating; server-enforced, never client-read
107
176
  "customCommissionRate", // commercial terms for this seller
@@ -212,6 +281,24 @@ export function toStoreListItem(doc, _opts) {
212
281
  allowSharedPurchase: doc.allowSharedPurchase ?? false,
213
282
  sharedPurchaseHandlingFee: doc.sharedPurchaseHandlingFee,
214
283
  sharedPurchaseExtraShippingPerDestination: doc.sharedPurchaseExtraShippingPerDestination,
284
+ /*
285
+ * 🛑 `?? false` and `?? "seller"` — the same tri-state rule as the
286
+ * protection flags, for a sharper reason. A badge component testing
287
+ * `undefined` loosely renders nothing, which is correct here, but a
288
+ * component testing `!== false` or `!== "seller"` would render a VERIFIED
289
+ * or TRUSTED badge on a store that has earned neither. Defaulting on the
290
+ * server means no client can make that mistake.
291
+ */
292
+ isVerified: doc.isVerified ?? false,
293
+ sellerTier: doc.sellerTier ?? "seller",
294
+ /*
295
+ * `standing` is passed through as `undefined` when absent, NOT defaulted
296
+ * to a zeroed object. "Never computed" and "computed, and you have zero
297
+ * delivered orders" are different facts, and the panel renders them
298
+ * differently — a confident "0 of 25" on a store that has simply never
299
+ * been measured is the regression Root Cause #72 names.
300
+ */
301
+ standing: doc.standing,
215
302
  createdAt: toIsoString(doc.createdAt),
216
303
  };
217
304
  }
@@ -7,6 +7,21 @@ export declare const couponExpiry: import("./types").ScheduledFunctionDefinition
7
7
  export declare const offerExpiry: import("./types").ScheduledFunctionDefinition;
8
8
  export declare const reservationExpiry: import("./types").ScheduledFunctionDefinition;
9
9
  export declare const productStatsSync: import("./types").ScheduledFunctionDefinition;
10
+ /**
11
+ * Seller standing — MONTHLY, on the last day of the month.
12
+ *
13
+ * 🛑 `28-31`, never `31`. Cloud Scheduler is unix-cron with no `L` token, so
14
+ * `0 2 31 * *` **never fires in February, April, June, September or
15
+ * November** — it would silently skip 5 of 12 months and present as a tier
16
+ * that updates "sometimes". This fires on each candidate day and
17
+ * `isLastDayOfMonth` in the core keeps exactly the real one, so at most 3 of
18
+ * the 4 invocations are no-ops that return before reading anything.
19
+ *
20
+ * 540s because it scans a 90-day window of orders and reviews, then writes
21
+ * one document per store. `maxInstances: 1` because two concurrent runs would
22
+ * both compute a tier and race on the same store documents.
23
+ */
24
+ export declare const sellerStandingRollup: import("./types").ScheduledFunctionDefinition;
10
25
  export declare const revenueRollup: import("./types").ScheduledFunctionDefinition;
11
26
  export declare const searchIndexBuild: import("./types").ScheduledFunctionDefinition;
12
27
  export declare const priceIndexRollup: import("./types").ScheduledFunctionDefinition;
@@ -100,4 +115,4 @@ export declare const prizeDrawExpiryReveal: import("./types").ScheduledFunctionD
100
115
  export declare const bundleStockSync: import("./types").ScheduledFunctionDefinition;
101
116
  export declare const emiInstallmentReminder: import("./types").ScheduledFunctionDefinition;
102
117
  export declare const catalogueImageStalenessReminder: import("./types").ScheduledFunctionDefinition;
103
- export declare const SCHEDULED_FUNCTIONS: readonly [import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition];
118
+ export declare const SCHEDULED_FUNCTIONS: readonly [import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition];
@@ -1,6 +1,6 @@
1
1
  // Scheduled function definitions (Cloud Scheduler triggers).
2
2
  // Cron syntax accepts both Firebase shorthand and standard cron expressions.
3
- import { auctionSettlementHandler, autoPayoutEligibilityHandler, bundleStockSyncHandler, cartPruneHandler, cleanupRtdbEventsHandler, countersReconcileHandler, boxRollupReconcileHandler, couponExpiryHandler, dailyDataCleanupHandler, draftPruneHandler, emiInstallmentReminderHandler, catalogueImageStalenessReminderHandler, mediaTmpCleanupHandler, notificationPruneHandler, offerExpiryHandler, reservationExpiryHandler, obligationSweepHandler, paymentReconcileHandler, paymentWindowTimeoutHandler, hardBanReinstatementHandler, paymentReviewAutoApproveHandler, pendingOrderTimeoutHandler, positionsReconcileHandler, prizeDrawExpiryRevealHandler, productStatsSyncHandler, revenueRollupHandler, priceIndexRollupHandler, marketPriceRollupHandler, searchIndexBuildHandler, analyticsAlertEvaluateHandler, pageViewRollupHandler, pageViewPruneHandler, dailyStatusDigestHandler, weeklyPayoutEligibilityHandler, testDataCleanupHandler, } from "../jobs/handlers/index.js";
3
+ import { auctionSettlementHandler, autoPayoutEligibilityHandler, bundleStockSyncHandler, cartPruneHandler, cleanupRtdbEventsHandler, countersReconcileHandler, boxRollupReconcileHandler, couponExpiryHandler, dailyDataCleanupHandler, draftPruneHandler, emiInstallmentReminderHandler, catalogueImageStalenessReminderHandler, mediaTmpCleanupHandler, notificationPruneHandler, offerExpiryHandler, reservationExpiryHandler, obligationSweepHandler, paymentReconcileHandler, paymentWindowTimeoutHandler, hardBanReinstatementHandler, paymentReviewAutoApproveHandler, pendingOrderTimeoutHandler, positionsReconcileHandler, prizeDrawExpiryRevealHandler, productStatsSyncHandler, revenueRollupHandler, sellerStandingRollupHandler, priceIndexRollupHandler, marketPriceRollupHandler, searchIndexBuildHandler, analyticsAlertEvaluateHandler, pageViewRollupHandler, pageViewPruneHandler, dailyStatusDigestHandler, weeklyPayoutEligibilityHandler, testDataCleanupHandler, } from "../jobs/handlers/index.js";
4
4
  import { defineFunction } from "./define.js";
5
5
  const REGION = "asia-south1";
6
6
  const EVERY_5_MIN = "every 5 minutes";
@@ -152,6 +152,27 @@ export const productStatsSync = defineFunction({
152
152
  handler: productStatsSyncHandler,
153
153
  options: { region: REGION, timeoutSeconds: 540, memory: "256MiB", maxInstances: 1 },
154
154
  });
155
+ /**
156
+ * Seller standing — MONTHLY, on the last day of the month.
157
+ *
158
+ * 🛑 `28-31`, never `31`. Cloud Scheduler is unix-cron with no `L` token, so
159
+ * `0 2 31 * *` **never fires in February, April, June, September or
160
+ * November** — it would silently skip 5 of 12 months and present as a tier
161
+ * that updates "sometimes". This fires on each candidate day and
162
+ * `isLastDayOfMonth` in the core keeps exactly the real one, so at most 3 of
163
+ * the 4 invocations are no-ops that return before reading anything.
164
+ *
165
+ * 540s because it scans a 90-day window of orders and reviews, then writes
166
+ * one document per store. `maxInstances: 1` because two concurrent runs would
167
+ * both compute a tier and race on the same store documents.
168
+ */
169
+ export const sellerStandingRollup = defineFunction({
170
+ name: "sellerStandingRollup",
171
+ description: "Recompute seller standing on the last day of each month and grant/revoke the Trusted Seller tier, which also sets trustedSeller (paid at shipping). Fires on days 28-31; the handler keeps only the real month-end.",
172
+ trigger: { kind: "schedule", cron: "0 2 28-31 * *", timeZone: "UTC" },
173
+ handler: sellerStandingRollupHandler,
174
+ options: { region: REGION, timeoutSeconds: 540, memory: "256MiB", maxInstances: 1 },
175
+ });
155
176
  export const revenueRollup = defineFunction({
156
177
  name: "revenueRollup",
157
178
  description: "Pre-compute total delivered-order revenue into analytics/dashboardRollup, replacing an unbounded per-request scan (daily 01:30 UTC).",
@@ -502,6 +523,7 @@ export const SCHEDULED_FUNCTIONS = [
502
523
  reservationExpiry,
503
524
  productStatsSync,
504
525
  revenueRollup,
526
+ sellerStandingRollup,
505
527
  priceIndexRollup,
506
528
  marketPriceRollup,
507
529
  searchIndexBuild,
@@ -299,7 +299,7 @@ function buildMetricCache(ctx, bucket, now, onSaturated) {
299
299
  * The owner's contact details, resolved once per owner.
300
300
  *
301
301
  * 🛑 This is how the seller's `notifyChannels` is HONOURED rather than
302
- * ignored. `sendNotification` decides email eligibility itself
302
+ * ignored. `sendNotification` decides email and WhatsApp eligibility itself
303
303
  * (type gate → admin allow-list → user prefs → daily budget) and takes no
304
304
  * channel list — but it cannot email an address it was not given. So omitting
305
305
  * `userEmail` when the seller did not tick email is a real narrowing through
@@ -389,6 +389,9 @@ export async function runAnalyticsAlertEvaluate(ctx) {
389
389
  ...(alert.notifyChannels?.includes("email") && contact.email
390
390
  ? { userEmail: contact.email }
391
391
  : {}),
392
+ ...(alert.notifyChannels?.includes("whatsapp") && contact.phone
393
+ ? { userPhone: contact.phone }
394
+ : {}),
392
395
  audience: b.scope === "admin" ? "admin" : "seller",
393
396
  });
394
397
  };
@@ -426,7 +429,7 @@ export async function runAnalyticsAlertEvaluate(ctx) {
426
429
  *
427
430
  * If the notification throws, the cooldown has still been recorded —
428
431
  * so a persistently failing channel cannot turn into one attempt per
429
- * run forever. The inverse order would retry a broken send
432
+ * run forever. The inverse order would retry a broken WhatsApp send
430
433
  * every hour against a 100/day email allowance.
431
434
  */
432
435
  await ctx.db
@@ -11,6 +11,7 @@
11
11
  import { runWeeklyPayoutEligibility } from "./weeklyPayoutEligibility.js";
12
12
  import { runHardBanCascade } from "./hardBanCascade.js";
13
13
  import { runResetOtpVerification } from "./resetOtpVerification.js";
14
+ import { runWhatsAppNotify } from "./whatsappNotify.js";
14
15
  import { runNewsletterExport } from "./newsletterExport.js";
15
16
  import { runStoreNameBackfill } from "./storeNameBackfill.js";
16
17
  import { runCategoryTaxonomyBackfill } from "./categoryTaxonomyBackfill.js";
@@ -41,6 +42,9 @@ async function runHardBanCascadeJob(payload, ctx) {
41
42
  async function runResetOtpVerificationJob(_payload, ctx) {
42
43
  return runResetOtpVerification(ctx);
43
44
  }
45
+ async function runWhatsAppNotifyJob(payload, ctx) {
46
+ return runWhatsAppNotify(payload, ctx);
47
+ }
44
48
  async function runNewsletterExportJob(_payload, ctx) {
45
49
  return runNewsletterExport(ctx);
46
50
  }
@@ -105,6 +109,7 @@ export const JOB_RUNNERS = {
105
109
  payoutsWeekly: runPayoutsWeeklyJob,
106
110
  hardBanCascade: runHardBanCascadeJob,
107
111
  resetOtpVerification: runResetOtpVerificationJob,
112
+ whatsappNotify: runWhatsAppNotifyJob,
108
113
  newsletterExport: runNewsletterExportJob,
109
114
  storeNameBackfill: runStoreNameBackfillJob,
110
115
  categoryTaxonomyBackfill: runCategoryTaxonomyBackfillJob,
@@ -1,7 +1,77 @@
1
1
  import { normalizeError } from "../../../../errors/normalize.js";
2
2
  import { ORDER_PII_FIELDS, revealPii } from "../../../../security/index.js";
3
- import { adminNotificationsRepository } from "../../../../repositories/index.js";
4
- import { buildPurchaseAnnouncementMessage } from "../../../../features/orders/utils/order-announcement-message.js";
3
+ import { adminNotificationsRepository, storeRepository, userRepository } from "../../../../repositories/index.js";
4
+ import { decryptPii } from "../../../../security/index.js";
5
+ import { sendWhatsAppBusinessMessage, buildPurchaseAnnouncementMessage, } from "../../../../features/whatsapp-bot/server.js";
6
+ import { resolveKeys } from "../../../../core/integration-keys.js";
7
+ import { guardSend, logSuppressedSend } from "../../notifications/send-guard.js";
8
+ async function sendAnnouncement(ctx, toPhone, message, phoneNumberId, accessToken, label, orderId) {
9
+ try {
10
+ const sent = await sendWhatsAppBusinessMessage({
11
+ toPhone,
12
+ message,
13
+ phoneNumberId,
14
+ accessToken,
15
+ });
16
+ if (sent) {
17
+ ctx.logger.info(`Announcement sent to ${label}`, {
18
+ orderId,
19
+ toPhone: `...${toPhone.slice(-4)}`,
20
+ });
21
+ }
22
+ else {
23
+ ctx.logger.error(`Announcement delivery failed for ${label} (non-fatal)`, null, { orderId });
24
+ }
25
+ }
26
+ catch (err) {
27
+ void normalizeError(err);
28
+ ctx.logger.error(`Announcement send threw for ${label} (non-fatal)`, err, { orderId });
29
+ }
30
+ }
31
+ async function notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId) {
32
+ try {
33
+ const store = await storeRepository.findBySlug(storeId);
34
+ if (!store?.ownerId)
35
+ return;
36
+ const owner = await userRepository.findById(store.ownerId);
37
+ const encryptedPhone = owner?.phoneNumber;
38
+ if (!encryptedPhone)
39
+ return;
40
+ const ownerPhone = decryptPii(encryptedPhone);
41
+ if (ownerPhone) {
42
+ await sendAnnouncement(ctx, ownerPhone, message, phoneNumberId, accessToken, "store-owner", orderId);
43
+ }
44
+ }
45
+ catch (err) {
46
+ void normalizeError(err);
47
+ ctx.logger.error("Store owner lookup failed (non-fatal)", err, { orderId, storeId });
48
+ }
49
+ }
50
+ /**
51
+ * Seeded credentials are `*_PLACEHOLDER` strings rather than empty, so an
52
+ * emptiness test alone would happily send with a fake token. Mirrors the guard
53
+ * in checkout-value-otp-actions.ts.
54
+ */
55
+ function usable(value) {
56
+ const v = value?.trim() ?? "";
57
+ return v && !v.includes("PLACEHOLDER") ? v : "";
58
+ }
59
+ async function resolveWhatsAppCredentials(ctx, orderId) {
60
+ let db = {};
61
+ try {
62
+ db = await resolveKeys();
63
+ }
64
+ catch (err) {
65
+ void normalizeError(err);
66
+ // Firestore unreachable from the Functions runtime — env-only is still a
67
+ // valid configuration, so this is a warning, not a failure.
68
+ ctx.logger.warn("Credential lookup failed — falling back to env", { orderId });
69
+ }
70
+ return {
71
+ phoneNumberId: usable(db.whatsappPhoneNumberId) || usable(ctx.env("WHATSAPP_PHONE_NUMBER_ID")),
72
+ accessToken: usable(db.whatsappCloudApiToken) || usable(ctx.env("WHATSAPP_CLOUD_API_TOKEN")),
73
+ };
74
+ }
5
75
  export async function handleOrderCreate(input, ctx) {
6
76
  const { orderId, order } = input;
7
77
  const items = order.items ?? [];
@@ -70,13 +140,37 @@ export async function handleOrderCreate(input, ctx) {
70
140
  ctx.logger.error("Failed to write admin order notification (non-fatal)", err, { orderId });
71
141
  }
72
142
  /*
73
- * The seller is told about their own sale by the `order_placed`
74
- * notification `createOrderForGroup` already sends (in-app + email). This
75
- * handler used to ALSO push a WhatsApp Cloud API message here — a duplicate
76
- * channel for the same event, and the only thing in this file that needed a
77
- * Meta token, a registered number and a billing relationship. Removed with
78
- * the rest of the WhatsApp integration on 2026-10-20; no signal was lost,
79
- * because the notification path is independent and unchanged.
143
+ * Seller side keeps WhatsApp: it is ONE message to ONE person about their
144
+ * own sale, which is the case the channel is good at. Credentials resolve
145
+ * DB-first (Admin → Site Settings → WhatsApp) with an env fallback — this
146
+ * read `ctx.env()` only until 2026-08-22, so credentials saved in Site
147
+ * Settings never reached the announcement and it silently no-op'd.
80
148
  */
81
- ctx.logger.info("Order announcement complete", { orderId, storeId: order.storeId ?? null });
149
+ const storeId = order.storeId;
150
+ if (!storeId) {
151
+ ctx.logger.info("Order has no storeId — nothing to announce to a seller", { orderId });
152
+ return;
153
+ }
154
+ const { phoneNumberId, accessToken } = await resolveWhatsAppCredentials(ctx, orderId);
155
+ if (!phoneNumberId || !accessToken) {
156
+ ctx.logger.info("WhatsApp Cloud API not configured — skipping seller announcement", { orderId });
157
+ return;
158
+ }
159
+ // Audience is "user": a seller receiving news of their own sale is a
160
+ // marketplace participant, not an operator of the site. Staff exemption is
161
+ // for the digest and the payout summary, and widening it to "anyone we think
162
+ // ought to see this" would make the kill switch mean nothing.
163
+ const guard = {
164
+ channel: "whatsapp",
165
+ feature: "order_placed_seller",
166
+ audience: "user",
167
+ };
168
+ const decision = await guardSend(guard);
169
+ if (!decision.allow) {
170
+ logSuppressedSend(guard, decision);
171
+ ctx.logger.info("Seller order announcement suppressed by messaging guard", { orderId, storeId });
172
+ return;
173
+ }
174
+ await notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId);
175
+ ctx.logger.info(`Order announcement complete`, { orderId, storeId });
82
176
  }
@@ -12,6 +12,8 @@ export type OrderAfter = {
12
12
  */
13
13
  paymentStatus?: string;
14
14
  invoiceNumber?: string;
15
+ /** The seller. Decides the WhatsApp arm via their notification subscription. */
16
+ storeId?: string;
15
17
  };
16
18
  export type OrderBefore = {
17
19
  status: OrderStatus;
@@ -119,6 +119,7 @@ export async function handleOrderStatusChange(input, ctx) {
119
119
  relatedId: orderId,
120
120
  relatedType: "order",
121
121
  userEmail,
122
+ storeId: after.storeId,
122
123
  });
123
124
  // The RTDB mirror that used to live here has been removed. `sendNotification`
124
125
  // above is the real path (in-app + email + WhatsApp); the extra
@@ -30,6 +30,7 @@ export async function handleScamReportCreate(input, ctx) {
30
30
  relatedId: scammerId,
31
31
  relatedType: "scammer",
32
32
  userEmail: reporterPlain?.email ?? undefined,
33
+ userPhone: reporterPlain?.phoneNumber ?? undefined,
33
34
  });
34
35
  }
35
36
  catch (err) {
@@ -28,6 +28,7 @@ export async function handleScamReportRejected(input, ctx) {
28
28
  relatedId: scammerId,
29
29
  relatedType: "scammer",
30
30
  userEmail: reporterPlain?.email ?? undefined,
31
+ userPhone: reporterPlain?.phoneNumber ?? undefined,
31
32
  });
32
33
  }
33
34
  catch (err) {
@@ -28,6 +28,7 @@ export async function handleScamReportVerified(input, ctx) {
28
28
  relatedId: scammerId,
29
29
  relatedType: "scammer",
30
30
  userEmail: reporterPlain?.email ?? undefined,
31
+ userPhone: reporterPlain?.phoneNumber ?? undefined,
31
32
  });
32
33
  }
33
34
  catch (err) {
@@ -123,6 +123,7 @@ export async function runPaymentWindowTimeout(ctx) {
123
123
  message: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_MESSAGE(entry.data.productTitle),
124
124
  relatedId: entry.id,
125
125
  relatedType: "order",
126
+ storeId: entry.data.storeId,
126
127
  });
127
128
  }
128
129
  catch (err) {
@@ -67,6 +67,7 @@ export async function runPendingOrderTimeout(ctx) {
67
67
  message: ORDER_MESSAGES.CANCELLED_TIMEOUT_MESSAGE(entry.data.productTitle, timeoutHours),
68
68
  relatedId: entry.id,
69
69
  relatedType: "order",
70
+ storeId: entry.data.storeId,
70
71
  });
71
72
  }
72
73
  catch (err) {