@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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core: MONTHLY seller-standing rollup — grants and revokes the Trusted
|
|
3
|
+
* Seller tier, and with it early payout.
|
|
4
|
+
*
|
|
5
|
+
* ## What it writes
|
|
6
|
+
*
|
|
7
|
+
* `stores/{id}.standing` — the cached metrics (this IS the cache; the seller's
|
|
8
|
+
* progress panel, the public badge and the admin view all read it off the
|
|
9
|
+
* store document they already load, and none of them recomputes anything).
|
|
10
|
+
* On a transition it also writes `sellerTier` and `trustedSeller` TOGETHER.
|
|
11
|
+
*
|
|
12
|
+
* 🛑 `trustedSeller` moves MONEY. It is read by both payout sweeps
|
|
13
|
+
* (`weeklyPayoutEligibility`, `autoPayoutEligibility`) via
|
|
14
|
+
* `storeRepository.findTrustedStoreIds()`, and it means the store is paid at
|
|
15
|
+
* SHIPPING instead of at delivery — so the platform carries a lost parcel with
|
|
16
|
+
* the money already gone. That is why this job is the only automatic writer,
|
|
17
|
+
* why every transition gets an audit row, and why an admin can pin a store
|
|
18
|
+
* with `standing.tierLockedByAdmin`.
|
|
19
|
+
*
|
|
20
|
+
* 🛑 The flag's author wrote, at `store.repository.ts`: *"trust is granted by
|
|
21
|
+
* hand, one store at a time, on risk grounds. If this ever saturates, the
|
|
22
|
+
* grant has stopped being deliberate."* Automatic granting is exactly that
|
|
23
|
+
* case, accepted deliberately by the owner. The mitigations are real and must
|
|
24
|
+
* not be quietly dropped: both a distinct-BUYER and a distinct-ADDRESS floor,
|
|
25
|
+
* self-bought orders excluded, test data excluded, and the 500-store bound on
|
|
26
|
+
* `findTrustedStoreIds` left in place so saturation is visible.
|
|
27
|
+
*/
|
|
28
|
+
import type { JobContext } from "../runtime/types";
|
|
29
|
+
export interface SellerStandingRollupResult {
|
|
30
|
+
/** Stores examined. */
|
|
31
|
+
examined: number;
|
|
32
|
+
/** Stores whose `standing` cache was written. */
|
|
33
|
+
written: number;
|
|
34
|
+
granted: string[];
|
|
35
|
+
revoked: string[];
|
|
36
|
+
/** Stores skipped because an admin pinned their tier. */
|
|
37
|
+
locked: number;
|
|
38
|
+
/** True when the month-end guard declined to run. */
|
|
39
|
+
skippedNotMonthEnd: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 🛑 THIS IS WHY THE CRON IS `0 2 28-31 * *` AND NOT `0 0 31 * *`.
|
|
43
|
+
*
|
|
44
|
+
* Cloud Scheduler uses unix-cron, which has no `L` (last-day) token. A cron on
|
|
45
|
+
* day 31 **never fires in February, April, June, September or November** — it
|
|
46
|
+
* would silently skip 5 of 12 months, and the symptom is not an error but a
|
|
47
|
+
* tier that updates "sometimes". So the schedule fires on each of the last four
|
|
48
|
+
* candidate days and this guard keeps exactly the real one.
|
|
49
|
+
*
|
|
50
|
+
* Uses UTC parts throughout, matching the scheduler's declared `timeZone: UTC`.
|
|
51
|
+
* A local-time implementation would disagree with the trigger near midnight.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isLastDayOfMonth(d: Date): boolean;
|
|
54
|
+
/** The next month-end at or after `d`, for the seller-facing `nextComputeAt`. */
|
|
55
|
+
export declare function nextLastDayOfMonth(d: Date): Date;
|
|
56
|
+
export interface SellerStandingRollupOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Run regardless of the date. For the admin "recompute now" path and for
|
|
59
|
+
* verification — NOT for the schedule.
|
|
60
|
+
*/
|
|
61
|
+
force?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export declare function runSellerStandingRollup(ctx: JobContext, opts?: SellerStandingRollupOptions): Promise<SellerStandingRollupResult>;
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core: MONTHLY seller-standing rollup — grants and revokes the Trusted
|
|
3
|
+
* Seller tier, and with it early payout.
|
|
4
|
+
*
|
|
5
|
+
* ## What it writes
|
|
6
|
+
*
|
|
7
|
+
* `stores/{id}.standing` — the cached metrics (this IS the cache; the seller's
|
|
8
|
+
* progress panel, the public badge and the admin view all read it off the
|
|
9
|
+
* store document they already load, and none of them recomputes anything).
|
|
10
|
+
* On a transition it also writes `sellerTier` and `trustedSeller` TOGETHER.
|
|
11
|
+
*
|
|
12
|
+
* 🛑 `trustedSeller` moves MONEY. It is read by both payout sweeps
|
|
13
|
+
* (`weeklyPayoutEligibility`, `autoPayoutEligibility`) via
|
|
14
|
+
* `storeRepository.findTrustedStoreIds()`, and it means the store is paid at
|
|
15
|
+
* SHIPPING instead of at delivery — so the platform carries a lost parcel with
|
|
16
|
+
* the money already gone. That is why this job is the only automatic writer,
|
|
17
|
+
* why every transition gets an audit row, and why an admin can pin a store
|
|
18
|
+
* with `standing.tierLockedByAdmin`.
|
|
19
|
+
*
|
|
20
|
+
* 🛑 The flag's author wrote, at `store.repository.ts`: *"trust is granted by
|
|
21
|
+
* hand, one store at a time, on risk grounds. If this ever saturates, the
|
|
22
|
+
* grant has stopped being deliberate."* Automatic granting is exactly that
|
|
23
|
+
* case, accepted deliberately by the owner. The mitigations are real and must
|
|
24
|
+
* not be quietly dropped: both a distinct-BUYER and a distinct-ADDRESS floor,
|
|
25
|
+
* self-bought orders excluded, test data excluded, and the 500-store bound on
|
|
26
|
+
* `findTrustedStoreIds` left in place so saturation is visible.
|
|
27
|
+
*/
|
|
28
|
+
import { storeRepository } from "../../../../repositories/index.js";
|
|
29
|
+
import { ORDER_FIELDS, REVIEW_FIELDS } from "../../../../constants/field-names.js";
|
|
30
|
+
import { recordAdminAction } from "../../features/audit-log/actions.js";
|
|
31
|
+
import { AdminAuditActionValues } from "../../../../features/audit-log/schemas/firestore.js";
|
|
32
|
+
import { sendNotification } from "../../../../features/admin/actions/notification-actions.js";
|
|
33
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
34
|
+
import { evaluateSellerStanding, SELLER_STANDING_DEFAULTS, SELLER_TIER, } from "../../../shared/features/seller-standing/config.js";
|
|
35
|
+
const EMPTY_RESULT = {
|
|
36
|
+
examined: 0,
|
|
37
|
+
written: 0,
|
|
38
|
+
granted: [],
|
|
39
|
+
revoked: [],
|
|
40
|
+
locked: 0,
|
|
41
|
+
skippedNotMonthEnd: true,
|
|
42
|
+
};
|
|
43
|
+
/** Firestore page size for the two windowed scans. */
|
|
44
|
+
const SCAN_PAGE = 500;
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// The month-end guard
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
/**
|
|
49
|
+
* 🛑 THIS IS WHY THE CRON IS `0 2 28-31 * *` AND NOT `0 0 31 * *`.
|
|
50
|
+
*
|
|
51
|
+
* Cloud Scheduler uses unix-cron, which has no `L` (last-day) token. A cron on
|
|
52
|
+
* day 31 **never fires in February, April, June, September or November** — it
|
|
53
|
+
* would silently skip 5 of 12 months, and the symptom is not an error but a
|
|
54
|
+
* tier that updates "sometimes". So the schedule fires on each of the last four
|
|
55
|
+
* candidate days and this guard keeps exactly the real one.
|
|
56
|
+
*
|
|
57
|
+
* Uses UTC parts throughout, matching the scheduler's declared `timeZone: UTC`.
|
|
58
|
+
* A local-time implementation would disagree with the trigger near midnight.
|
|
59
|
+
*/
|
|
60
|
+
export function isLastDayOfMonth(d) {
|
|
61
|
+
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1));
|
|
62
|
+
return next.getUTCMonth() !== d.getUTCMonth();
|
|
63
|
+
}
|
|
64
|
+
/** The next month-end at or after `d`, for the seller-facing `nextComputeAt`. */
|
|
65
|
+
export function nextLastDayOfMonth(d) {
|
|
66
|
+
// Day 0 of the following month is the last day of this one.
|
|
67
|
+
const endOfThis = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
|
|
68
|
+
if (endOfThis.getTime() > d.getTime())
|
|
69
|
+
return endOfThis;
|
|
70
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 2, 0));
|
|
71
|
+
}
|
|
72
|
+
const emptyTally = () => ({
|
|
73
|
+
deliveredOrders: 0,
|
|
74
|
+
buyers: new Set(),
|
|
75
|
+
addresses: new Set(),
|
|
76
|
+
ratingSum: 0,
|
|
77
|
+
ratingCount: 0,
|
|
78
|
+
});
|
|
79
|
+
function toMetrics(t) {
|
|
80
|
+
return {
|
|
81
|
+
deliveredOrders: t.deliveredOrders,
|
|
82
|
+
distinctBuyers: t.buyers.size,
|
|
83
|
+
distinctAddresses: t.addresses.size,
|
|
84
|
+
ratedOrders: t.ratingCount,
|
|
85
|
+
/*
|
|
86
|
+
* 0 when there are no ratings, never NaN. `ratingSum / 0` is NaN, and NaN
|
|
87
|
+
* fails every `>=` comparison — which happens to give the right verdict
|
|
88
|
+
* here but writes NaN into Firestore, where it is rejected outright on
|
|
89
|
+
* some paths and renders as "NaN" in the seller's panel on others.
|
|
90
|
+
*/
|
|
91
|
+
averageRating: t.ratingCount === 0 ? 0 : t.ratingSum / t.ratingCount,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function isTestRow(data) {
|
|
95
|
+
/*
|
|
96
|
+
* Both spellings. `isTestData` is the legacy boolean and `seedPurpose` is
|
|
97
|
+
* the newer field it is derived from; a row may carry either, so testing one
|
|
98
|
+
* would let the other through and let a tester fixture count toward a real
|
|
99
|
+
* money-moving grant.
|
|
100
|
+
*/
|
|
101
|
+
if (data.isTestData === true)
|
|
102
|
+
return true;
|
|
103
|
+
return data.seedPurpose === "test" || data.seedPurpose === "tester";
|
|
104
|
+
}
|
|
105
|
+
export async function runSellerStandingRollup(ctx, opts = {}) {
|
|
106
|
+
if (!opts.force && !isLastDayOfMonth(ctx.now)) {
|
|
107
|
+
ctx.logger.info("sellerStandingRollup: not month-end, skipping", {
|
|
108
|
+
now: ctx.now.toISOString(),
|
|
109
|
+
});
|
|
110
|
+
return { ...EMPTY_RESULT };
|
|
111
|
+
}
|
|
112
|
+
/*
|
|
113
|
+
* 🛑 The CONSTANT, not a settings read.
|
|
114
|
+
*
|
|
115
|
+
* This briefly folded an admin override from `siteSettings.sellerStanding`
|
|
116
|
+
* onto the defaults. That made the job disagree with `/seller-guide` and
|
|
117
|
+
* `/help/seller-badges`, which render their criteria from the code defaults
|
|
118
|
+
* — so retuning the numbers would have changed what the tier is granted on
|
|
119
|
+
* while both public pages went on promising the old figures. One constant,
|
|
120
|
+
* every surface, agreeing by construction.
|
|
121
|
+
*/
|
|
122
|
+
const thresholds = SELLER_STANDING_DEFAULTS;
|
|
123
|
+
const cutoff = new Date(ctx.now.getTime() - thresholds.windowDays * 24 * 60 * 60 * 1000);
|
|
124
|
+
const nextComputeAt = nextLastDayOfMonth(ctx.now);
|
|
125
|
+
ctx.logger.info("sellerStandingRollup: starting", {
|
|
126
|
+
windowDays: thresholds.windowDays,
|
|
127
|
+
cutoff: cutoff.toISOString(),
|
|
128
|
+
forced: Boolean(opts.force),
|
|
129
|
+
});
|
|
130
|
+
// ── Stores ───────────────────────────────────────────────────────────────
|
|
131
|
+
/*
|
|
132
|
+
* Enumerate STORES, not the orders' distinct storeIds. A store that sold
|
|
133
|
+
* nothing this window must still be evaluated, because that is precisely the
|
|
134
|
+
* store whose tier should be REVOKED — a reconciler built from the events
|
|
135
|
+
* that exist can only ever add (Root Cause #102).
|
|
136
|
+
*/
|
|
137
|
+
const stores = new Map();
|
|
138
|
+
const storeSnap = await ctx.db
|
|
139
|
+
.collection("stores")
|
|
140
|
+
.select("ownerId", "status", "sellerTier", "trustedSeller", "tierLockedByAdmin")
|
|
141
|
+
.get();
|
|
142
|
+
for (const doc of storeSnap.docs) {
|
|
143
|
+
const d = doc.data();
|
|
144
|
+
stores.set(doc.id, {
|
|
145
|
+
id: doc.id,
|
|
146
|
+
ownerId: d.ownerId,
|
|
147
|
+
status: d.status,
|
|
148
|
+
sellerTier: d.sellerTier,
|
|
149
|
+
trustedSeller: d.trustedSeller,
|
|
150
|
+
tierLockedByAdmin: d.tierLockedByAdmin === true,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const tallies = new Map();
|
|
154
|
+
const tally = (storeId) => {
|
|
155
|
+
let t = tallies.get(storeId);
|
|
156
|
+
if (!t) {
|
|
157
|
+
t = emptyTally();
|
|
158
|
+
tallies.set(storeId, t);
|
|
159
|
+
}
|
|
160
|
+
return t;
|
|
161
|
+
};
|
|
162
|
+
// ── Orders in the window ─────────────────────────────────────────────────
|
|
163
|
+
/*
|
|
164
|
+
* 🛑 Windowed on `orderDate`, which is REQUIRED, not on `deliveryDate`,
|
|
165
|
+
* which is optional and written by exactly one job (`obligationSweep`). A
|
|
166
|
+
* Firestore inequality EXCLUDES every document lacking the field, so a range
|
|
167
|
+
* on `deliveryDate` would silently drop every order a seller marked
|
|
168
|
+
* delivered by hand — returning a confident undercount with no error
|
|
169
|
+
* anywhere (Root Cause #100).
|
|
170
|
+
*
|
|
171
|
+
* One collection-wide windowed scan rather than a query per store: a
|
|
172
|
+
* per-store `storeId == X AND orderDate >= cutoff` needs a composite index,
|
|
173
|
+
* and a missing one fails as FAILED_PRECONDITION that every caller here
|
|
174
|
+
* would have to not-swallow (Root Cause #2). A single-field inequality is
|
|
175
|
+
* served by the automatic index. `status` is filtered in memory for the same
|
|
176
|
+
* reason.
|
|
177
|
+
*/
|
|
178
|
+
let orderCursor = null;
|
|
179
|
+
let ordersScanned = 0;
|
|
180
|
+
for (;;) {
|
|
181
|
+
let q = ctx.db
|
|
182
|
+
.collection("orders")
|
|
183
|
+
.where(ORDER_FIELDS.ORDER_DATE, ">=", cutoff)
|
|
184
|
+
.orderBy(ORDER_FIELDS.ORDER_DATE, "asc")
|
|
185
|
+
.limit(SCAN_PAGE);
|
|
186
|
+
if (orderCursor)
|
|
187
|
+
q = q.startAfter(orderCursor);
|
|
188
|
+
const snap = await q.get();
|
|
189
|
+
if (snap.empty)
|
|
190
|
+
break;
|
|
191
|
+
for (const doc of snap.docs) {
|
|
192
|
+
const d = doc.data();
|
|
193
|
+
ordersScanned += 1;
|
|
194
|
+
if (d.status !== "delivered")
|
|
195
|
+
continue;
|
|
196
|
+
if (isTestRow(d))
|
|
197
|
+
continue;
|
|
198
|
+
const storeId = d.storeId;
|
|
199
|
+
if (!storeId)
|
|
200
|
+
continue;
|
|
201
|
+
const store = stores.get(storeId);
|
|
202
|
+
if (!store)
|
|
203
|
+
continue;
|
|
204
|
+
const userId = d.userId;
|
|
205
|
+
/*
|
|
206
|
+
* 🛑 Self-bought orders do not count. Without this a seller reaches the
|
|
207
|
+
* tier — and early payout — by buying from themselves 25 times, which is
|
|
208
|
+
* the cheapest possible attack on a criterion that releases money early.
|
|
209
|
+
*/
|
|
210
|
+
if (userId && store.ownerId && userId === store.ownerId)
|
|
211
|
+
continue;
|
|
212
|
+
const t = tally(storeId);
|
|
213
|
+
t.deliveredOrders += 1;
|
|
214
|
+
if (userId)
|
|
215
|
+
t.buyers.add(userId);
|
|
216
|
+
/*
|
|
217
|
+
* 🛑 `deliveryAddressId` — the `addresses` row this order was taken
|
|
218
|
+
* from. It is a provenance pointer, NOT PII and NOT encrypted, so
|
|
219
|
+
* counting distinct values needs no decryption and no fingerprint of
|
|
220
|
+
* our own.
|
|
221
|
+
*
|
|
222
|
+
* Orders predating the delivery snapshot carry none and contribute
|
|
223
|
+
* nothing to this criterion. That is forward-only by design: inventing
|
|
224
|
+
* a key from `deliveryPincode` instead would count two different
|
|
225
|
+
* households in one pincode as one address, and the encrypted
|
|
226
|
+
* `deliveryAddressLine1` cannot be read here at all.
|
|
227
|
+
*
|
|
228
|
+
* Honest about its limit: a determined seller can save the same physical
|
|
229
|
+
* place as several address rows. The distinct-BUYER floor is the real
|
|
230
|
+
* defence; this one raises the cost.
|
|
231
|
+
*/
|
|
232
|
+
if (d.deliveryAddressId)
|
|
233
|
+
t.addresses.add(d.deliveryAddressId);
|
|
234
|
+
}
|
|
235
|
+
orderCursor = snap.docs[snap.docs.length - 1];
|
|
236
|
+
if (snap.size < SCAN_PAGE)
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
// ── Reviews in the window ────────────────────────────────────────────────
|
|
240
|
+
/*
|
|
241
|
+
* Windowed on `createdAt`, which every document carries via `BaseDocument`.
|
|
242
|
+
* `ReviewDocument` has no `publishedAt` field at all — CLAUDE.md's seed
|
|
243
|
+
* table says it does, and that is stale.
|
|
244
|
+
*/
|
|
245
|
+
let reviewCursor = null;
|
|
246
|
+
for (;;) {
|
|
247
|
+
let q = ctx.db
|
|
248
|
+
.collection("reviews")
|
|
249
|
+
.where(REVIEW_FIELDS.CREATED_AT, ">=", cutoff)
|
|
250
|
+
.orderBy(REVIEW_FIELDS.CREATED_AT, "asc")
|
|
251
|
+
.limit(SCAN_PAGE);
|
|
252
|
+
if (reviewCursor)
|
|
253
|
+
q = q.startAfter(reviewCursor);
|
|
254
|
+
const snap = await q.get();
|
|
255
|
+
if (snap.empty)
|
|
256
|
+
break;
|
|
257
|
+
for (const doc of snap.docs) {
|
|
258
|
+
const d = doc.data();
|
|
259
|
+
if (d.status !== "approved")
|
|
260
|
+
continue;
|
|
261
|
+
if (isTestRow(d))
|
|
262
|
+
continue;
|
|
263
|
+
const storeId = d.storeId;
|
|
264
|
+
if (!storeId || !stores.has(storeId))
|
|
265
|
+
continue;
|
|
266
|
+
const rating = Number(d.rating);
|
|
267
|
+
if (!Number.isFinite(rating))
|
|
268
|
+
continue;
|
|
269
|
+
const t = tally(storeId);
|
|
270
|
+
t.ratingSum += rating;
|
|
271
|
+
t.ratingCount += 1;
|
|
272
|
+
}
|
|
273
|
+
reviewCursor = snap.docs[snap.docs.length - 1];
|
|
274
|
+
if (snap.size < SCAN_PAGE)
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
// ── Evaluate + write ─────────────────────────────────────────────────────
|
|
278
|
+
/*
|
|
279
|
+
* 🛑 Through the REPOSITORY, not `ctx.db.update()`.
|
|
280
|
+
*
|
|
281
|
+
* `adminUpdate` runs `withStoreHistory`, and `sellerTier` / `trustedSeller`
|
|
282
|
+
* are both in `STORE_TRACKED_FIELDS` — so a transition earns a
|
|
283
|
+
* `statusHistory` entry stamped `actorRole: "system"`. A raw document write
|
|
284
|
+
* would move money with no timeline entry, which is the one thing the
|
|
285
|
+
* timeline exists for on this field.
|
|
286
|
+
*
|
|
287
|
+
* `prior` is the projected shell rather than a full document, which is safe
|
|
288
|
+
* and deliberate: `withHistory` diffs only `tracked` fields that appear in
|
|
289
|
+
* the PATCH, and the patch only ever carries `standing` (untracked),
|
|
290
|
+
* `sellerTier` and `trustedSeller` — both of which the shell selects. The
|
|
291
|
+
* cast says exactly that and nothing more.
|
|
292
|
+
*/
|
|
293
|
+
const writeStore = (store, patch) => storeRepository.adminUpdate(store.id, patch, { actor: { role: "system" }, trigger: "sellerStandingRollup" }, store);
|
|
294
|
+
const granted = [];
|
|
295
|
+
const revoked = [];
|
|
296
|
+
let written = 0;
|
|
297
|
+
let locked = 0;
|
|
298
|
+
for (const store of stores.values()) {
|
|
299
|
+
const metrics = toMetrics(tallies.get(store.id) ?? emptyTally());
|
|
300
|
+
const verdict = evaluateSellerStanding(metrics, thresholds);
|
|
301
|
+
/*
|
|
302
|
+
* `standing` is a pure MEASUREMENT — no admin state in it. The pin lives
|
|
303
|
+
* at the top level precisely so this object can be rebuilt wholesale every
|
|
304
|
+
* month without carrying a decision forward or needing to be merged.
|
|
305
|
+
*/
|
|
306
|
+
const standing = {
|
|
307
|
+
computedAt: ctx.now,
|
|
308
|
+
nextComputeAt,
|
|
309
|
+
windowDays: thresholds.windowDays,
|
|
310
|
+
...metrics,
|
|
311
|
+
met: verdict.met,
|
|
312
|
+
};
|
|
313
|
+
/*
|
|
314
|
+
* An admin-pinned store still gets a REFRESHED `standing` — the seller
|
|
315
|
+
* should see their real figures — but its tier is left exactly where the
|
|
316
|
+
* admin put it. Writing the metrics while not acting on them is the whole
|
|
317
|
+
* point of the lock.
|
|
318
|
+
*/
|
|
319
|
+
if (store.tierLockedByAdmin) {
|
|
320
|
+
locked += 1;
|
|
321
|
+
await writeStore(store, { standing });
|
|
322
|
+
written += 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
/*
|
|
326
|
+
* 🛑 A store that is not ACTIVE can never hold the tier. Suspension
|
|
327
|
+
* already clears it immediately (see `storeRepository.setStatus`), and
|
|
328
|
+
* this is the belt: a suspended store must not be re-granted early payout
|
|
329
|
+
* by a month-end run just because its 90-day history still looks good.
|
|
330
|
+
*/
|
|
331
|
+
const eligible = store.status === "active" && verdict.tier === SELLER_TIER.TRUSTED;
|
|
332
|
+
const currentTier = store.sellerTier ?? SELLER_TIER.SELLER;
|
|
333
|
+
const nextTier = eligible ? SELLER_TIER.TRUSTED : SELLER_TIER.SELLER;
|
|
334
|
+
const update = { standing };
|
|
335
|
+
const isTransition = nextTier !== currentTier || Boolean(store.trustedSeller) !== eligible;
|
|
336
|
+
if (isTransition) {
|
|
337
|
+
update.sellerTier = nextTier;
|
|
338
|
+
update.trustedSeller = eligible;
|
|
339
|
+
if (eligible)
|
|
340
|
+
granted.push(store.id);
|
|
341
|
+
else
|
|
342
|
+
revoked.push(store.id);
|
|
343
|
+
}
|
|
344
|
+
await writeStore(store, update);
|
|
345
|
+
written += 1;
|
|
346
|
+
if (!isTransition)
|
|
347
|
+
continue;
|
|
348
|
+
/*
|
|
349
|
+
* 🛑 Audit and notify on a REAL transition only. Re-saving the monthly
|
|
350
|
+
* `standing` on an unchanged store would otherwise write an audit row
|
|
351
|
+
* describing a decision nobody made and email a seller about nothing —
|
|
352
|
+
* and a log of non-events is how a log stops being read. Same rule the
|
|
353
|
+
* admin PATCH route applies to this exact field.
|
|
354
|
+
*/
|
|
355
|
+
void recordAdminAction({
|
|
356
|
+
actorUid: "system",
|
|
357
|
+
action: AdminAuditActionValues.STORE_TRUST_CHANGE,
|
|
358
|
+
targetType: "store",
|
|
359
|
+
targetId: store.id,
|
|
360
|
+
targetLabel: store.id,
|
|
361
|
+
reason: eligible
|
|
362
|
+
? "Met every Trusted Seller criterion at the monthly recompute"
|
|
363
|
+
: "Fell below a Trusted Seller criterion at the monthly recompute",
|
|
364
|
+
metadata: {
|
|
365
|
+
trustedSeller: eligible,
|
|
366
|
+
previous: Boolean(store.trustedSeller),
|
|
367
|
+
sellerTier: nextTier,
|
|
368
|
+
trigger: "sellerStandingRollup",
|
|
369
|
+
...metrics,
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
if (!store.ownerId)
|
|
373
|
+
continue;
|
|
374
|
+
/*
|
|
375
|
+
* The seller is told, because the consequence is concrete in both
|
|
376
|
+
* directions: gaining the tier changes when they get paid, and losing it
|
|
377
|
+
* changes it back. Fire-and-forget — a notification failure must not
|
|
378
|
+
* abort a sweep that has already written the tier.
|
|
379
|
+
*/
|
|
380
|
+
void sendNotification({
|
|
381
|
+
userId: store.ownerId,
|
|
382
|
+
type: "system",
|
|
383
|
+
priority: "normal",
|
|
384
|
+
title: eligible ? "You're now a Trusted Seller" : "Trusted Seller status paused",
|
|
385
|
+
message: eligible
|
|
386
|
+
? "Your store met every Trusted Seller criterion this month. Your badge is live and your payouts now start when you ship, rather than on delivery."
|
|
387
|
+
: `Your store no longer meets every Trusted Seller criterion, so the badge is paused and payouts return to starting on delivery. ${verdict.unmetCount === 1 ? "One criterion" : `${verdict.unmetCount} criteria`} to regain it — see your seller guide for the current figures.`,
|
|
388
|
+
}).catch((err) => {
|
|
389
|
+
const e = normalizeError(err);
|
|
390
|
+
ctx.logger.warn("sellerStandingRollup: notification failed", {
|
|
391
|
+
storeId: store.id,
|
|
392
|
+
error: e.message,
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
ctx.logger.info("sellerStandingRollup: complete", {
|
|
397
|
+
examined: stores.size,
|
|
398
|
+
ordersScanned,
|
|
399
|
+
written,
|
|
400
|
+
granted: granted.length,
|
|
401
|
+
revoked: revoked.length,
|
|
402
|
+
locked,
|
|
403
|
+
});
|
|
404
|
+
return {
|
|
405
|
+
examined: stores.size,
|
|
406
|
+
written,
|
|
407
|
+
granted,
|
|
408
|
+
revoked,
|
|
409
|
+
locked,
|
|
410
|
+
skippedNotMonthEnd: false,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core: async WhatsApp notification dispatch (`whatsappNotify` job).
|
|
3
|
+
*
|
|
4
|
+
* `sendNotification()` (appkit/src/features/admin/actions/notification-actions.ts)
|
|
5
|
+
* no longer calls the Meta Cloud API synchronously in-request — it enqueues
|
|
6
|
+
* this job instead (CLAUDE.md Rule #6 async job primitive) and returns
|
|
7
|
+
* immediately. This runner does the actual send, with a bounded retry, and
|
|
8
|
+
* writes the outcome back onto `notifications/{notificationId}` so admins
|
|
9
|
+
* have visibility beyond the `jobs/{jobId}` doc itself.
|
|
10
|
+
*
|
|
11
|
+
* Retry note: `sendWhatsAppTemplateMessage`/`sendWhatsAppBusinessMessage`
|
|
12
|
+
* return a plain boolean (not a status code), so this runner cannot cleanly
|
|
13
|
+
* distinguish a transient 5xx from a terminal 4xx (bad token, unknown
|
|
14
|
+
* template) the way a status-code-aware client could. It retries any
|
|
15
|
+
* failure up to MAX_ATTEMPTS with a short backoff — an acceptable
|
|
16
|
+
* simplification since this runs off the critical path, well inside the
|
|
17
|
+
* Firebase Function's 300s budget, and a terminal error just burns two
|
|
18
|
+
* extra fast failures before giving up.
|
|
19
|
+
*/
|
|
20
|
+
import type { JobContext } from "../runtime/types";
|
|
21
|
+
import type { JobRunResult } from "./jobRunners";
|
|
22
|
+
export interface WhatsAppNotifyPayload {
|
|
23
|
+
toPhone: string;
|
|
24
|
+
title: string;
|
|
25
|
+
message: string;
|
|
26
|
+
type: string;
|
|
27
|
+
/** Approved Meta template name for this notification type, if configured. */
|
|
28
|
+
templateName?: string;
|
|
29
|
+
/** BCP-47 language code the template was approved in. Defaults to "en". */
|
|
30
|
+
templateLanguage?: string;
|
|
31
|
+
notificationId: string;
|
|
32
|
+
phoneNumberId: string;
|
|
33
|
+
accessToken: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function runWhatsAppNotify(payload: WhatsAppNotifyPayload, ctx: JobContext): Promise<JobRunResult>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core: async WhatsApp notification dispatch (`whatsappNotify` job).
|
|
3
|
+
*
|
|
4
|
+
* `sendNotification()` (appkit/src/features/admin/actions/notification-actions.ts)
|
|
5
|
+
* no longer calls the Meta Cloud API synchronously in-request — it enqueues
|
|
6
|
+
* this job instead (CLAUDE.md Rule #6 async job primitive) and returns
|
|
7
|
+
* immediately. This runner does the actual send, with a bounded retry, and
|
|
8
|
+
* writes the outcome back onto `notifications/{notificationId}` so admins
|
|
9
|
+
* have visibility beyond the `jobs/{jobId}` doc itself.
|
|
10
|
+
*
|
|
11
|
+
* Retry note: `sendWhatsAppTemplateMessage`/`sendWhatsAppBusinessMessage`
|
|
12
|
+
* return a plain boolean (not a status code), so this runner cannot cleanly
|
|
13
|
+
* distinguish a transient 5xx from a terminal 4xx (bad token, unknown
|
|
14
|
+
* template) the way a status-code-aware client could. It retries any
|
|
15
|
+
* failure up to MAX_ATTEMPTS with a short backoff — an acceptable
|
|
16
|
+
* simplification since this runs off the critical path, well inside the
|
|
17
|
+
* Firebase Function's 300s budget, and a terminal error just burns two
|
|
18
|
+
* extra fast failures before giving up.
|
|
19
|
+
*/
|
|
20
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
21
|
+
import { notificationRepository } from "../../../../features/admin/repository/notification.repository.js";
|
|
22
|
+
import { sendWhatsAppTemplateMessage, sendWhatsAppBusinessMessage, } from "../../../../features/whatsapp-bot/helpers/whatsapp.js";
|
|
23
|
+
import { isChannelHealthy, recordChannelOutcome } from "../../notifications/channel-health.js";
|
|
24
|
+
import { guardSend, logSuppressedSend } from "../../notifications/send-guard.js";
|
|
25
|
+
const MAX_ATTEMPTS = 3;
|
|
26
|
+
const RETRY_DELAY_MS = 1500;
|
|
27
|
+
function delay(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
export async function runWhatsAppNotify(payload, ctx) {
|
|
31
|
+
const { toPhone, title, message, type, templateName, templateLanguage, notificationId, phoneNumberId, accessToken } = payload;
|
|
32
|
+
/*
|
|
33
|
+
* The messaging guard, applied HERE rather than inside
|
|
34
|
+
* `sendWhatsAppBusinessMessage` itself.
|
|
35
|
+
*
|
|
36
|
+
* 🛑 That placement is deliberate and must not be "improved" by pushing a
|
|
37
|
+
* required guard parameter down into the sender. `features/whatsapp-bot/helpers/whatsapp.ts`
|
|
38
|
+
* is re-exported from `appkit/src/index.ts` (via `isAdminNumber`), and
|
|
39
|
+
* `guardSend` reaches `firebase-admin` through the settings and budget
|
|
40
|
+
* repositories. A static import of it from that file would put
|
|
41
|
+
* `firebase-admin` in the main entry's import graph, which is precisely the
|
|
42
|
+
* Turbopack client-bundle trap in Root Cause #6/#24 — it builds fine locally
|
|
43
|
+
* on webpack and fails every page in production.
|
|
44
|
+
*
|
|
45
|
+
* So the senders stay pure and each of the three real call sites guards
|
|
46
|
+
* itself; `scripts/audit-unguarded-send.mjs` enforces that a file calling a
|
|
47
|
+
* WhatsApp sender also calls `guardSend`.
|
|
48
|
+
*
|
|
49
|
+
* Note this job is only ever reached for an email/WhatsApp-ELIGIBLE type —
|
|
50
|
+
* `sendNotification` returns before enqueuing otherwise — so this is the
|
|
51
|
+
* ceiling and kill-switch check, not the type check.
|
|
52
|
+
*/
|
|
53
|
+
const guard = {
|
|
54
|
+
channel: "whatsapp",
|
|
55
|
+
feature: type,
|
|
56
|
+
audience: "user",
|
|
57
|
+
};
|
|
58
|
+
const decision = await guardSend(guard);
|
|
59
|
+
if (!decision.allow) {
|
|
60
|
+
logSuppressedSend(guard, decision);
|
|
61
|
+
await notificationRepository
|
|
62
|
+
.update(notificationId, { whatsappStatus: "skipped" })
|
|
63
|
+
.catch((err) => {
|
|
64
|
+
void normalizeError(err);
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
summary: { total: 1, succeeded: 0, skipped: 1, failed: 0 },
|
|
68
|
+
succeeded: [],
|
|
69
|
+
skipped: [notificationId],
|
|
70
|
+
failed: [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (!(await isChannelHealthy("whatsapp"))) {
|
|
74
|
+
ctx.logger.warn("whatsappNotify: WhatsApp channel circuit is open — skipping send", { notificationId });
|
|
75
|
+
await notificationRepository
|
|
76
|
+
.update(notificationId, { whatsappStatus: "skipped" })
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
void normalizeError(err);
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
summary: { total: 1, succeeded: 0, skipped: 1, failed: 0 },
|
|
82
|
+
succeeded: [],
|
|
83
|
+
skipped: [notificationId],
|
|
84
|
+
failed: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
let sent = false;
|
|
88
|
+
let lastError;
|
|
89
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS && !sent; attempt++) {
|
|
90
|
+
try {
|
|
91
|
+
sent = templateName
|
|
92
|
+
? await sendWhatsAppTemplateMessage({
|
|
93
|
+
toPhone,
|
|
94
|
+
phoneNumberId,
|
|
95
|
+
accessToken,
|
|
96
|
+
templateName,
|
|
97
|
+
languageCode: templateLanguage ?? "en",
|
|
98
|
+
bodyParams: [title, message],
|
|
99
|
+
})
|
|
100
|
+
: await sendWhatsAppBusinessMessage({
|
|
101
|
+
toPhone,
|
|
102
|
+
phoneNumberId,
|
|
103
|
+
accessToken,
|
|
104
|
+
message: `*${title}*\n${message}`,
|
|
105
|
+
});
|
|
106
|
+
if (!sent)
|
|
107
|
+
lastError = "Meta WhatsApp API returned a non-OK response";
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
void normalizeError(err);
|
|
111
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
112
|
+
}
|
|
113
|
+
if (!sent && attempt < MAX_ATTEMPTS) {
|
|
114
|
+
ctx.logger.warn("whatsappNotify: send attempt failed, retrying", { notificationId, attempt, error: lastError ?? "" });
|
|
115
|
+
await delay(RETRY_DELAY_MS * attempt);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!templateName) {
|
|
119
|
+
ctx.logger.warn("whatsappNotify: no approved template configured for this notification type — sent as free-form text, which Meta rejects outside the 24h customer-service window", { notificationId });
|
|
120
|
+
}
|
|
121
|
+
await recordChannelOutcome("whatsapp", sent);
|
|
122
|
+
await notificationRepository
|
|
123
|
+
.update(notificationId, { whatsappStatus: sent ? "sent" : "failed" })
|
|
124
|
+
.catch((err) => {
|
|
125
|
+
void normalizeError(err);
|
|
126
|
+
ctx.logger.warn("whatsappNotify: failed to write back whatsappStatus onto notification doc", {
|
|
127
|
+
notificationId,
|
|
128
|
+
error: err instanceof Error ? err.message : String(err),
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
if (!sent) {
|
|
132
|
+
throw new Error(lastError ?? "WhatsApp send failed after retries");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
summary: { total: 1, succeeded: 1, skipped: 0, failed: 0 },
|
|
136
|
+
succeeded: [notificationId],
|
|
137
|
+
skipped: [],
|
|
138
|
+
failed: [],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -32,6 +32,7 @@ export { hardBanReinstatementHandler } from "./hardBanReinstatement";
|
|
|
32
32
|
export { paymentReviewAutoApproveHandler } from "./paymentReviewAutoApprove";
|
|
33
33
|
export { productStatsSyncHandler } from "./productStatsSync";
|
|
34
34
|
export { revenueRollupHandler } from "./revenueRollup";
|
|
35
|
+
export { sellerStandingRollupHandler } from "./sellerStandingRollup";
|
|
35
36
|
export { priceIndexRollupHandler } from "./priceIndexRollup";
|
|
36
37
|
export { marketPriceRollupHandler } from "./marketPriceRollup";
|
|
37
38
|
export { searchIndexBuildHandler } from "./searchIndexBuild";
|
|
@@ -32,6 +32,7 @@ export { hardBanReinstatementHandler } from "./hardBanReinstatement.js";
|
|
|
32
32
|
export { paymentReviewAutoApproveHandler } from "./paymentReviewAutoApprove.js";
|
|
33
33
|
export { productStatsSyncHandler } from "./productStatsSync.js";
|
|
34
34
|
export { revenueRollupHandler } from "./revenueRollup.js";
|
|
35
|
+
export { sellerStandingRollupHandler } from "./sellerStandingRollup.js";
|
|
35
36
|
export { priceIndexRollupHandler } from "./priceIndexRollup.js";
|
|
36
37
|
export { marketPriceRollupHandler } from "./marketPriceRollup.js";
|
|
37
38
|
export { searchIndexBuildHandler } from "./searchIndexBuild.js";
|