@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
- package/dist/_internal/server/features/categories/adapters.js +13 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
- package/dist/_internal/server/features/checkout/actions.js +22 -5
- package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
- package/dist/_internal/server/features/events/like-actions.js +67 -0
- package/dist/_internal/server/features/events/likes.d.ts +65 -0
- package/dist/_internal/server/features/events/likes.js +146 -0
- package/dist/_internal/server/features/orders/adapters.js +3 -1
- package/dist/_internal/server/features/seo/index.d.ts +3 -0
- package/dist/_internal/server/features/seo/index.js +2 -0
- package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
- package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
- package/dist/_internal/server/features/seo/robots.js +20 -1
- package/dist/_internal/server/features/seo/sitemap.js +125 -27
- package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
- package/dist/_internal/server/features/seo/static-params.js +228 -0
- package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
- package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
- package/dist/_internal/server/features/site-settings/adapters.js +20 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +88 -1
- package/dist/_internal/server/functions/scheduled.d.ts +16 -1
- package/dist/_internal/server/functions/scheduled.js +23 -1
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
- package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
- package/dist/_internal/server/notifications/channel-health.js +2 -2
- package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
- package/dist/_internal/server/notifications/send-guard.js +4 -2
- package/dist/_internal/shared/actions/action-registry.js +89 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
- package/dist/_internal/shared/checkout/order-math.js +1 -1
- package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
- package/dist/_internal/shared/features/checkout/config.js +2 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
- package/dist/_internal/shared/features/messaging/config.js +4 -1
- package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
- package/dist/_internal/shared/features/seller-standing/config.js +153 -0
- package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
- package/dist/_internal/shared/features/site-settings/fees.js +2 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
- package/dist/_internal/shared/fees/calculator.d.ts +8 -0
- package/dist/_internal/shared/fees/calculator.js +7 -0
- package/dist/cli/index.js +2 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +39 -0
- package/dist/constants/api-endpoints.js +16 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/constants/notification-types.js +1 -1
- package/dist/core/integration-keys.d.ts +5 -0
- package/dist/core/integration-keys.js +10 -0
- package/dist/features/about/components/FeesView.js +6 -0
- package/dist/features/about/components/HelpPageView.js +9 -1
- package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
- package/dist/features/account/components/BecomeSellerView.js +2 -2
- package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
- package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
- package/dist/features/account/types/index.d.ts +2 -0
- package/dist/features/admin/actions/notification-actions.d.ts +17 -2
- package/dist/features/admin/actions/notification-actions.js +154 -9
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
- package/dist/features/admin/components/AdminOrdersView.js +2 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +31 -2
- package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
- package/dist/features/admin/components/AdminStoresView.js +59 -2
- package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
- package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
- package/dist/features/admin/constants/site-settings-tabs.js +12 -6
- package/dist/features/admin/repository/site-settings.repository.js +1 -1
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
- package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
- package/dist/features/admin/schemas/admin-user-form.js +44 -3
- package/dist/features/admin/schemas/firestore.d.ts +73 -0
- package/dist/features/admin/schemas/firestore.js +8 -3
- package/dist/features/auth/checkout-value-otp.d.ts +1 -1
- package/dist/features/auth/checkout-value-otp.js +1 -1
- package/dist/features/auth/permissions/constants.d.ts +2 -2
- package/dist/features/auth/permissions/constants.js +2 -1
- package/dist/features/blog/components/BlogIndexPageView.js +2 -2
- package/dist/features/blog/components/BlogListView.js +2 -1
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/blog/constants/category-badge.d.ts +33 -0
- package/dist/features/blog/constants/category-badge.js +40 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
- package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
- package/dist/features/cart/repository/cart.repository.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +2 -1
- package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
- package/dist/features/categories/schemas/firestore.d.ts +25 -0
- package/dist/features/categories/utils/load-category-options.d.ts +33 -0
- package/dist/features/categories/utils/load-category-options.js +50 -0
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
- package/dist/features/events/components/EventCard.js +4 -12
- package/dist/features/events/components/EventInsights.d.ts +27 -0
- package/dist/features/events/components/EventInsights.js +10 -0
- package/dist/features/events/components/EventLikeButton.d.ts +21 -0
- package/dist/features/events/components/EventLikeButton.js +59 -0
- package/dist/features/events/components/EventLikersRail.d.ts +23 -0
- package/dist/features/events/components/EventLikersRail.js +54 -0
- package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
- package/dist/features/events/components/EventTypeBadge.js +7 -0
- package/dist/features/events/components/EventsListPageView.js +2 -2
- package/dist/features/events/components/SpinWheel.d.ts +41 -0
- package/dist/features/events/components/SpinWheel.js +128 -0
- package/dist/features/events/components/SpinWheelView.js +36 -15
- package/dist/features/events/constants/event-type-meta.d.ts +54 -0
- package/dist/features/events/constants/event-type-meta.js +109 -0
- package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
- package/dist/features/events/repository/event-likes.repository.js +151 -0
- package/dist/features/events/schemas/firestore.d.ts +111 -0
- package/dist/features/events/schemas/likes.d.ts +81 -0
- package/dist/features/events/schemas/likes.js +59 -0
- package/dist/features/events/types/index.d.ts +7 -0
- package/dist/features/events/utils/carousel-slide.d.ts +42 -0
- package/dist/features/events/utils/carousel-slide.js +174 -0
- package/dist/features/events/utils/event-charts.d.ts +65 -0
- package/dist/features/events/utils/event-charts.js +172 -0
- package/dist/features/faq/constants/help-guides.js +9 -0
- package/dist/features/faq/schemas/firestore.js +1 -0
- package/dist/features/faq/types/index.d.ts +19 -0
- package/dist/features/homepage/components/BrandsSection.js +25 -1
- package/dist/features/homepage/components/HeroCarousel.js +47 -10
- package/dist/features/homepage/lib/section-renderer.js +1 -1
- package/dist/features/homepage/types/index.d.ts +7 -0
- package/dist/features/orders/actions/refund-actions.js +1 -0
- package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
- package/dist/features/orders/components/OrderAddonBadges.js +4 -3
- package/dist/features/orders/repository/orders.repository.d.ts +8 -0
- package/dist/features/orders/repository/orders.repository.js +2 -0
- package/dist/features/orders/schemas/firestore.d.ts +4 -0
- package/dist/features/orders/types/index.d.ts +5 -2
- package/dist/features/search/action-index/settings-entries.js +9 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
- package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
- package/dist/features/store-extensions/schemas/firestore.js +10 -0
- package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
- package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
- package/dist/features/stores/components/SellerStandingPanel.js +25 -0
- package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
- package/dist/features/stores/components/SellerTierBadge.js +19 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +33 -2
- package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
- package/dist/features/stores/components/StoreCreateForm.js +107 -0
- package/dist/features/stores/components/StoreGuideHubView.js +18 -5
- package/dist/features/stores/components/StoreHeader.js +2 -1
- package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
- package/dist/features/stores/repository/store.repository.d.ts +23 -1
- package/dist/features/stores/repository/store.repository.js +52 -1
- package/dist/features/stores/schemas/firestore.d.ts +76 -7
- package/dist/features/stores/schemas/firestore.js +21 -6
- package/dist/features/stores/schemas/index.d.ts +88 -2
- package/dist/features/stores/schemas/index.js +14 -0
- package/dist/features/stores/schemas/store-create.d.ts +47 -0
- package/dist/features/stores/schemas/store-create.js +86 -0
- package/dist/features/stores/types/index.d.ts +42 -0
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
- package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/components/index.js +2 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
- package/dist/features/whatsapp-bot/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/index.js +3 -0
- package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
- package/dist/features/whatsapp-bot/manifest.js +13 -0
- package/dist/features/whatsapp-bot/messages/en.json +7 -0
- package/dist/features/whatsapp-bot/server.d.ts +7 -0
- package/dist/features/whatsapp-bot/server.js +7 -0
- package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
- package/dist/features/whatsapp-bot/types/index.js +1 -0
- package/dist/index.d.ts +23 -6
- package/dist/index.js +38 -12
- package/dist/next/routing/route-map.d.ts +6 -1
- package/dist/next/routing/route-map.js +14 -0
- package/dist/providers/db-firebase/base.repository.d.ts +1 -1
- package/dist/providers/db-firebase/base.repository.js +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.js +1 -1
- package/dist/schemas/registry.d.ts +115 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server.d.ts +15 -2
- package/dist/server.js +32 -2
- package/dist/styles.css +36 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/HorizontalScroller.js +15 -2
- package/dist/ui/components/NavigationLoader.d.ts +14 -1
- package/dist/ui/components/NavigationLoader.js +79 -30
- package/dist/ui/components/PageHeroHeading.d.ts +40 -0
- package/dist/ui/components/PageHeroHeading.js +15 -0
- package/dist/ui/components/Typography.d.ts +3 -3
- package/dist/ui/components/Typography.js +59 -31
- package/dist/ui/components/Typography.style.css +35 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -2
- package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
- 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", //
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
@@ -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) {
|