@mohasinac/appkit 4.63.4 → 4.63.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  3. package/dist/_internal/server/features/checkout/actions.js +22 -5
  4. package/dist/_internal/server/features/orders/adapters.js +3 -1
  5. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  6. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  7. package/dist/_internal/server/features/stores/adapters.js +1 -0
  8. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  9. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  10. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  11. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  12. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  13. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  14. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  15. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  16. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  17. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  18. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  19. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  20. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  21. package/dist/_internal/server/notifications/channel-health.js +2 -2
  22. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  23. package/dist/_internal/server/notifications/send-guard.js +4 -2
  24. package/dist/_internal/shared/actions/action-registry.js +28 -0
  25. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  26. package/dist/_internal/shared/checkout/order-math.js +1 -1
  27. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  28. package/dist/_internal/shared/features/checkout/config.js +2 -0
  29. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  30. package/dist/_internal/shared/features/messaging/config.js +4 -1
  31. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  32. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  33. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  34. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  35. package/dist/_internal/shared/fees/calculator.js +7 -0
  36. package/dist/cli/index.js +2 -0
  37. package/dist/client.d.ts +2 -2
  38. package/dist/client.js +1 -1
  39. package/dist/constants/api-endpoints.d.ts +15 -0
  40. package/dist/constants/api-endpoints.js +8 -0
  41. package/dist/constants/notification-types.d.ts +1 -1
  42. package/dist/constants/notification-types.js +1 -1
  43. package/dist/core/integration-keys.d.ts +5 -0
  44. package/dist/core/integration-keys.js +10 -0
  45. package/dist/features/about/components/FeesView.js +6 -0
  46. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  47. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  48. package/dist/features/account/types/index.d.ts +2 -0
  49. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  50. package/dist/features/admin/actions/notification-actions.js +154 -9
  51. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  52. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  53. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  54. package/dist/features/admin/components/AdminStoreEditorView.js +1 -0
  55. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  56. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  57. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  58. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  59. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  60. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  61. package/dist/features/admin/schemas/firestore.js +8 -3
  62. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  63. package/dist/features/auth/checkout-value-otp.js +1 -1
  64. package/dist/features/auth/permissions/constants.d.ts +2 -2
  65. package/dist/features/auth/permissions/constants.js +2 -1
  66. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  67. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  68. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  69. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  70. package/dist/features/cart/repository/cart.repository.js +2 -1
  71. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  72. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  73. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  74. package/dist/features/homepage/lib/section-renderer.js +1 -1
  75. package/dist/features/orders/actions/refund-actions.js +1 -0
  76. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  77. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  78. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  79. package/dist/features/orders/repository/orders.repository.js +2 -0
  80. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  81. package/dist/features/orders/types/index.d.ts +5 -2
  82. package/dist/features/search/action-index/settings-entries.js +9 -0
  83. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  84. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  85. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  86. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  87. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +1 -0
  88. package/dist/features/stores/components/StoreGuideHubView.js +1 -1
  89. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  90. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  91. package/dist/features/stores/repository/store.repository.js +29 -1
  92. package/dist/features/stores/schemas/firestore.d.ts +16 -6
  93. package/dist/features/stores/schemas/firestore.js +3 -6
  94. package/dist/features/stores/schemas/index.d.ts +86 -0
  95. package/dist/features/stores/schemas/index.js +14 -0
  96. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  97. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  98. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  99. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  100. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  101. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  102. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  103. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  104. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  105. package/dist/features/whatsapp-bot/components/index.js +2 -0
  106. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  107. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  108. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  109. package/dist/features/whatsapp-bot/index.js +3 -0
  110. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  111. package/dist/features/whatsapp-bot/manifest.js +13 -0
  112. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  113. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  114. package/dist/features/whatsapp-bot/server.js +7 -0
  115. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  116. package/dist/features/whatsapp-bot/types/index.js +1 -0
  117. package/dist/index.d.ts +23 -6
  118. package/dist/index.js +38 -12
  119. package/dist/next/routing/route-map.d.ts +3 -0
  120. package/dist/next/routing/route-map.js +1 -0
  121. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  122. package/dist/providers/db-firebase/base.repository.js +1 -1
  123. package/dist/repositories/index.d.ts +1 -1
  124. package/dist/repositories/index.js +1 -1
  125. package/dist/schemas/registry.d.ts +105 -0
  126. package/dist/schemas/registry.js +2 -1
  127. package/dist/security/pii-encrypt.js +1 -1
  128. package/dist/server.d.ts +10 -2
  129. package/dist/server.js +22 -2
  130. package/dist/styles.css +1 -1
  131. package/dist/tailwind-utilities.css +1 -1
  132. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  133. package/dist/ui/components/NavigationLoader.js +79 -30
  134. package/package.json +1 -1
  135. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  136. package/dist/features/orders/utils/order-announcement-message.js +0 -16
@@ -25,7 +25,7 @@ const SECTIONS = [
25
25
  },
26
26
  {
27
27
  Icon: CheckCircle, title: "Order Confirmation",
28
- content: (_jsxs(_Fragment, { children: [_jsx(Text, { className: GC.textMuted, children: "After a successful payment you'll see an on-screen confirmation with your order ID. You'll also receive:" }), _jsxs(Ul, { className: `${GC.listDiscMuted} mt-3`, children: [_jsx(Li, { children: "An email confirmation to your registered address" }), _jsx(Li, { children: "An in-app notification you can read any time from the bell in the header" })] }), _jsxs(Text, { className: `${GC.textMuted} mt-3`, children: ["View all your orders at any time under ", _jsx(Span, { weight: "bold", children: "My Account \u2192 My Orders" }), "."] })] })),
28
+ content: (_jsxs(_Fragment, { children: [_jsx(Text, { className: GC.textMuted, children: "After a successful payment you'll see an on-screen confirmation with your order ID. You'll also receive:" }), _jsxs(Ul, { className: `${GC.listDiscMuted} mt-3`, children: [_jsx(Li, { children: "An email confirmation to your registered address" }), _jsx(Li, { children: "A WhatsApp message if you've added and verified your phone number" })] }), _jsxs(Text, { className: `${GC.textMuted} mt-3`, children: ["View all your orders at any time under ", _jsx(Span, { weight: "bold", children: "My Account \u2192 My Orders" }), "."] })] })),
29
29
  },
30
30
  {
31
31
  Icon: UserX, title: "Guest Limitations",
@@ -30,7 +30,7 @@ export function NotificationPreferencesPanel({ fetchUrl = ACCOUNT_ENDPOINTS.NOTI
30
30
  const { showToast } = useToast();
31
31
  const { data: settings } = useSiteSettings();
32
32
  const adminChannels = settings?.notificationChannels;
33
- const [channels, setChannels] = useState({ email: true, sms: true });
33
+ const [channels, setChannels] = useState({ email: true, whatsapp: true, sms: true });
34
34
  const [types, setTypes] = useState({
35
35
  orderUpdates: true, bids: true, promotions: true,
36
36
  system: true, reviews: true, messages: true, offers: true,
@@ -92,7 +92,8 @@ export function NotificationPreferencesPanel({ fetchUrl = ACCOUNT_ENDPOINTS.NOTI
92
92
  }
93
93
  // Which channels are admin-enabled (only show those toggles to the user)
94
94
  const emailAdminEnabled = adminChannels?.email?.enabled ?? false;
95
+ const whatsappAdminEnabled = adminChannels?.whatsapp?.enabled ?? false;
95
96
  const smsAdminEnabled = adminChannels?.sms?.enabled ?? false;
96
- const anyChannelEnabled = emailAdminEnabled || smsAdminEnabled;
97
- return (_jsxs(Stack, { gap: "lg", children: [_jsx(SectionCard, { title: "In-app notifications", children: _jsxs(Row, { align: "center", justify: "between", gap: "md", padding: "y-sm", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "medium", children: "In-app notifications" }), _jsx(Text, { variant: "secondary", className: "mt-0.5", size: "xs", children: "Always on \u2014 displayed in the notification bell and inbox" })] }), _jsx(Row, { className: "h-5 w-9 bg-[var(--appkit-color-primary)] px-[0.125rem]", align: "center", justify: "end", rounded: "full", "aria-label": "Always on", children: _jsx(Span, { className: "h-4 w-4", rounded: "full", surface: "default", shadow: "default" }) })] }) }), anyChannelEnabled && (_jsxs(SectionCard, { title: "Notification channels", children: [emailAdminEnabled && (_jsx(ToggleRow, { label: "Email", description: "Receive notifications by email", checked: channels.email !== false, onChange: (v) => setChannel("email", v) })), smsAdminEnabled && (_jsx(ToggleRow, { label: "SMS", description: "Receive SMS text notifications", checked: channels.sms !== false, onChange: (v) => setChannel("sms", v) }))] })), _jsx(SectionCard, { title: "Notification types", children: TYPE_ROWS.map((row) => (_jsx(ToggleRow, { label: row.label, description: row.description, checked: types[row.key] !== false, onChange: (v) => setType(row.key, v) }, row.key))) }), _jsxs(Row, { justify: "end", gap: "sm", children: [saved && (_jsx(Text, { className: "text-[var(--appkit-color-success)] self-center", size: "sm", children: "Preferences saved \u2713" })), _jsx(Button, { onClick: handleSave, isLoading: saving, size: "sm", children: "Save preferences" })] })] }));
97
+ const anyChannelEnabled = emailAdminEnabled || whatsappAdminEnabled || smsAdminEnabled;
98
+ return (_jsxs(Stack, { gap: "lg", children: [_jsx(SectionCard, { title: "In-app notifications", children: _jsxs(Row, { align: "center", justify: "between", gap: "md", padding: "y-sm", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "medium", children: "In-app notifications" }), _jsx(Text, { variant: "secondary", className: "mt-0.5", size: "xs", children: "Always on \u2014 displayed in the notification bell and inbox" })] }), _jsx(Row, { className: "h-5 w-9 bg-[var(--appkit-color-primary)] px-[0.125rem]", align: "center", justify: "end", rounded: "full", "aria-label": "Always on", children: _jsx(Span, { className: "h-4 w-4", rounded: "full", surface: "default", shadow: "default" }) })] }) }), anyChannelEnabled && (_jsxs(SectionCard, { title: "Notification channels", children: [emailAdminEnabled && (_jsx(ToggleRow, { label: "Email", description: "Receive notifications by email", checked: channels.email !== false, onChange: (v) => setChannel("email", v) })), whatsappAdminEnabled && (_jsx(ToggleRow, { label: "WhatsApp", description: "Receive notifications on WhatsApp", checked: channels.whatsapp !== false, onChange: (v) => setChannel("whatsapp", v) })), smsAdminEnabled && (_jsx(ToggleRow, { label: "SMS", description: "Receive SMS text notifications", checked: channels.sms !== false, onChange: (v) => setChannel("sms", v) }))] })), _jsx(SectionCard, { title: "Notification types", children: TYPE_ROWS.map((row) => (_jsx(ToggleRow, { label: row.label, description: row.description, checked: types[row.key] !== false, onChange: (v) => setType(row.key, v) }, row.key))) }), _jsxs(Row, { justify: "end", gap: "sm", children: [saved && (_jsx(Text, { className: "text-[var(--appkit-color-success)] self-center", size: "sm", children: "Preferences saved \u2713" })), _jsx(Button, { onClick: handleSave, isLoading: saving, size: "sm", children: "Save preferences" })] })] }));
98
99
  }
@@ -75,6 +75,8 @@ export interface UserAddress {
75
75
  export interface NotificationChannelPrefs {
76
76
  /** Disable all email notifications for this user (default: true = enabled). */
77
77
  email?: boolean;
78
+ /** Disable all WhatsApp notifications for this user. */
79
+ whatsapp?: boolean;
78
80
  /** Disable all SMS notifications for this user. */
79
81
  sms?: boolean;
80
82
  }
@@ -11,8 +11,21 @@ export declare function getUnreadNotificationCount(userId: string): Promise<numb
11
11
  export interface SendNotificationInput extends NotificationCreateInput {
12
12
  /** User's email — used by the email channel when enabled. */
13
13
  userEmail?: string;
14
+ /** User's phone with country code, digits only — used by WhatsApp channel. */
15
+ userPhone?: string;
14
16
  /** Pre-rendered email HTML — falls back to a plain-text <p> when absent. */
15
17
  emailHtml?: string;
18
+ /**
19
+ * The store this notification is about, for the order-lifecycle types in
20
+ * `ORDER_GATED_TYPES`. Its WhatsApp subscription decides whether the
21
+ * WhatsApp arm runs; absent, those types fall back to in-app + email.
22
+ *
23
+ * Resolved HERE rather than by each caller: six call sites would otherwise
24
+ * each do their own store read and each be a place the rule could drift
25
+ * (Root Cause #75). The read only happens when the type is gated AND
26
+ * WhatsApp is otherwise eligible, so the common path costs nothing.
27
+ */
28
+ storeId?: string;
16
29
  /**
17
30
  * Which portal the recipient reads this in — decides where `actionUrl`
18
31
  * points when the caller does not supply one.
@@ -48,15 +61,17 @@ export interface SendNotificationInput extends NotificationCreateInput {
48
61
  export interface SendNotificationResult {
49
62
  notification: NotificationDocument;
50
63
  email: "sent" | "skipped" | "failed";
64
+ /** WhatsApp dispatch is now async (enqueued as a `whatsappNotify` job) — "queued" replaces "sent" as the immediate outcome; the real delivery result lands on `notification.whatsappStatus`. */
65
+ whatsapp: "queued" | "skipped" | "failed";
51
66
  }
52
67
  /**
53
68
  * Create an in-app notification and fan out to enabled external channels.
54
69
  *
55
- * In-app is always written. Email is sent only when:
70
+ * In-app is always written. Email / WhatsApp are sent only when:
56
71
  * 1. The channel is enabled in siteSettings.notificationChannels
57
72
  * 2. Notification priority meets the channel's minPriority threshold
58
73
  * 3. The optional `types` filter includes this notification type (or is absent)
59
- * 4. The contact field (userEmail) is supplied by the caller
74
+ * 4. The contact field (userEmail / userPhone) is supplied by the caller
60
75
  * 5. The required credentials are set in siteSettings.credentials
61
76
  */
62
77
  export declare function sendNotification(input: SendNotificationInput): Promise<SendNotificationResult>;
@@ -2,13 +2,15 @@ import { normalizeError } from "../../../errors/normalize.js";
2
2
  import { notificationRepository } from "../repository/notification.repository.js";
3
3
  import { siteSettingsRepository } from "../repository/site-settings.repository.js";
4
4
  import { userRepository } from "../../auth/repository/user.repository.js";
5
+ import { storeRepository } from "../../stores/repository/store.repository.js";
6
+ import { safeRead } from "../../../errors/safe-read.js";
5
7
  import { createResendProvider } from "../../../providers/email-resend/provider.js";
6
8
  import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
9
+ import { enqueueJob } from "../../jobs/actions/enqueue-job.js";
7
10
  import { serverLogger } from "../../../monitoring/index.js";
8
11
  import { resolveNotificationActionUrl, TYPE_AUDIENCE, } from "../../../_internal/shared/features/notifications/action-url.js";
9
12
  import { isEmailEligible } from "../../../_internal/shared/features/notifications/email-eligibility.js";
10
13
  import { renderNotificationEmail } from "../../email/notification-templates.js";
11
- import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore.js";
12
14
  /**
13
15
  * A notification's `actionUrl` is a site-relative path — correct for the
14
16
  * in-app bell, useless in an email, where a bare `/user/orders/x` is not a
@@ -31,6 +33,85 @@ function absoluteActionUrl(path) {
31
33
  const base = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, "");
32
34
  return base ? `${base}${path}` : path;
33
35
  }
36
+ import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore.js";
37
+ /**
38
+ * Notification types whose WhatsApp send is gated behind the SELLER's
39
+ * standing WhatsApp-notifications subscription — see `storeId` on
40
+ * `SendNotificationInput`.
41
+ *
42
+ * 🛑 This replaced a per-ORDER ₹10 addon the buyer paid at checkout
43
+ * (`OrderDocument.whatsappNotifyAddon`). The axis moved deliberately: the
44
+ * platform owns one WhatsApp number, so the cost is a standing monthly one
45
+ * borne by the seller who benefits from it, not a line item a buyer opts into
46
+ * per order. A buyer should not have to purchase their own delivery updates.
47
+ *
48
+ * Every other type (bid_won, offers, scam reports, support tickets, admin
49
+ * alerts, …) keeps the original two gates — admin channel enabled, user has
50
+ * not opted out — unchanged.
51
+ */
52
+ /**
53
+ * Does this store subscribe to WhatsApp order notifications?
54
+ *
55
+ * The subscription is a monthly add-on priced by the admin
56
+ * (`commissions.whatsappNotifyMonthlyFee`); this reads only whether it is
57
+ * currently active. Billing lives with the other recurring charges, not here.
58
+ *
59
+ * 🛑 Fails CLOSED. A store we cannot read, or one that has not subscribed,
60
+ * gets no WhatsApp send — the buyer still receives the in-app notification and
61
+ * the email, which are ungated. Failing open would message customers on behalf
62
+ * of a seller who is not paying for it, and bill the platform for the template.
63
+ */
64
+ async function storeWhatsAppSubscribed(storeId) {
65
+ if (!storeId)
66
+ return false;
67
+ const store = await safeRead(() => storeRepository.findBySlug(storeId), {
68
+ route: "sendNotification",
69
+ key: `whatsapp-subscription:${storeId}`,
70
+ fallback: null,
71
+ });
72
+ return store?.whatsappNotifySubscribed === true;
73
+ }
74
+ const ORDER_GATED_TYPES = new Set([
75
+ "order_placed",
76
+ "order_confirmed",
77
+ "order_shipped",
78
+ "order_delivered",
79
+ "order_cancelled",
80
+ "refund_initiated",
81
+ ]);
82
+ /**
83
+ * Notification type → the flat `SiteSettingsCredentials` field holding its
84
+ * approved Meta template name.
85
+ *
86
+ * 🛑 **`Partial` here is CORRECT and is not the `typeToPrefsKey` mistake.**
87
+ *
88
+ * The distinction is what a missing key MEANS. A missing `typeToPrefsKey`
89
+ * entry silently ignored a user's opt-out — a real setting that read as
90
+ * honoured and was not. A missing entry here means "no Meta-approved template
91
+ * exists for this type", which is a fact about the Meta account, not a bug:
92
+ * six of the 28 types have one, and the rest legitimately fall back to
93
+ * free-form (which Meta accepts inside the 24-hour customer-service window
94
+ * and rejects outside it — the runner already logs that rejection).
95
+ *
96
+ * Getting six approved templates is a Meta Business review process, not a
97
+ * code change, so listing all 28 here with empty values would be a
98
+ * `Record<…>` that lies about coverage.
99
+ *
100
+ * The six that ARE approved are the order lifecycle, which is also the set
101
+ * gated behind the buyer's ₹10 addon above — the two lists agree on purpose.
102
+ */
103
+ const WHATSAPP_TEMPLATE_FIELD = {
104
+ order_placed: "whatsappTemplateOrderPlaced",
105
+ order_confirmed: "whatsappTemplateOrderConfirmed",
106
+ order_shipped: "whatsappTemplateOrderShipped",
107
+ order_delivered: "whatsappTemplateOrderDelivered",
108
+ order_cancelled: "whatsappTemplateOrderCancelled",
109
+ refund_initiated: "whatsappTemplateRefundInitiated",
110
+ };
111
+ function resolveWhatsAppTemplateName(type, creds) {
112
+ const field = WHATSAPP_TEMPLATE_FIELD[type];
113
+ return (field ? creds[field] : undefined)?.trim() ?? "";
114
+ }
34
115
  export async function markNotificationRead(id) {
35
116
  await notificationRepository.markAsRead(id);
36
117
  }
@@ -53,15 +134,15 @@ export async function getUnreadNotificationCount(userId) {
53
134
  /**
54
135
  * Create an in-app notification and fan out to enabled external channels.
55
136
  *
56
- * In-app is always written. Email is sent only when:
137
+ * In-app is always written. Email / WhatsApp are sent only when:
57
138
  * 1. The channel is enabled in siteSettings.notificationChannels
58
139
  * 2. Notification priority meets the channel's minPriority threshold
59
140
  * 3. The optional `types` filter includes this notification type (or is absent)
60
- * 4. The contact field (userEmail) is supplied by the caller
141
+ * 4. The contact field (userEmail / userPhone) is supplied by the caller
61
142
  * 5. The required credentials are set in siteSettings.credentials
62
143
  */
63
144
  export async function sendNotification(input) {
64
- const { userEmail, emailHtml, audience, eligibilityOverride, ...notifInput } = input;
145
+ const { userEmail, userPhone, emailHtml, audience, eligibilityOverride, ...notifInput } = input;
65
146
  /*
66
147
  * Fill `actionUrl` when the caller did not.
67
148
  *
@@ -103,12 +184,12 @@ export async function sendNotification(input) {
103
184
  * suppresses the emailed/messaged COPY, not the notification.
104
185
  *
105
186
  * One map gates both channels deliberately. If a type is ever genuinely
106
- * worth a channel of its own, split the map then rather than
187
+ * WhatsApp-worthy but not email-worthy, split the map then rather than
107
188
  * loosening this early return.
108
189
  */
109
190
  if (!isEmailEligible(notifInput.type) &&
110
191
  eligibilityOverride !== "staff_requested") {
111
- return { notification, email: "skipped" };
192
+ return { notification, email: "skipped", whatsapp: "skipped" };
112
193
  }
113
194
  // Load channel config + credentials (one Firestore read).
114
195
  let settings;
@@ -118,7 +199,7 @@ export async function sendNotification(input) {
118
199
  catch (err) {
119
200
  void normalizeError(err);
120
201
  serverLogger.warn("sendNotification: could not load siteSettings — skipping external channels", { err });
121
- return { notification, email: "skipped" };
202
+ return { notification, email: "skipped", whatsapp: "skipped" };
122
203
  }
123
204
  const channels = settings.notificationChannels ?? DEFAULT_NOTIFICATION_CHANNELS;
124
205
  const creds = settings.credentials ?? {};
@@ -150,6 +231,7 @@ export async function sendNotification(input) {
150
231
  ? userRepository.revealUser(fetchedUserDoc)
151
232
  : null;
152
233
  const resolvedEmail = userEmail ?? fetchedUserPlain?.email ?? undefined;
234
+ const resolvedPhone = userPhone ?? fetchedUserPlain?.phoneNumber ?? undefined;
153
235
  // Map NotificationType to a NotificationTypePrefs key.
154
236
  /*
155
237
  * 🛑 COMPLETE and compile-checked: `Record<NotificationType, …>`, not
@@ -212,7 +294,7 @@ export async function sendNotification(input) {
212
294
  const typeKey = typeToPrefsKey[type];
213
295
  // If the user has explicitly disabled this notification type, skip all external channels.
214
296
  if (typeKey && userTypePrefs[typeKey] === false) {
215
- return { notification, email: "skipped" };
297
+ return { notification, email: "skipped", whatsapp: "skipped" };
216
298
  }
217
299
  // Email channel — admin enabled AND user hasn't opted out
218
300
  let emailStatus = "skipped";
@@ -259,10 +341,73 @@ export async function sendNotification(input) {
259
341
  }
260
342
  }
261
343
  }
344
+ // WhatsApp channel — admin enabled AND user hasn't opted out AND (for
345
+ // order-lifecycle types) the buyer paid the per-order WhatsApp addon.
346
+ let whatsappStatus = "skipped";
347
+ if (channels.whatsapp.enabled &&
348
+ userChannelPrefs.whatsapp !== false &&
349
+ resolvedPhone &&
350
+ meetsMinPriority(priority, channels.whatsapp.minPriority) &&
351
+ (!channels.whatsapp.types?.length || channels.whatsapp.types.includes(type)) &&
352
+ (!ORDER_GATED_TYPES.has(type) || (await storeWhatsAppSubscribed(input.storeId)))) {
353
+ /*
354
+ * ENV FIRST, site settings second.
355
+ *
356
+ * The platform owns one WhatsApp number, so its credentials are
357
+ * deployment configuration rather than admin-editable content — they
358
+ * belong beside the other secrets in the env, not in a Firestore document
359
+ * an admin screen can rewrite. Rotating a leaked Meta token should be a
360
+ * redeploy, not a form submission.
361
+ *
362
+ * 🛑 `WHATSAPP_PHONE_NUMBER_ID` is the Cloud API's numeric id, NOT the
363
+ * dialable number: sends go to `/{phone-number-id}/messages`, and a `+1…`
364
+ * string there is a 404 that looks like a routing bug.
365
+ *
366
+ * The site-settings fallback is kept so an install that configured these
367
+ * through the admin screen keeps working — this changes precedence, not
368
+ * behaviour, and nothing has to be migrated.
369
+ */
370
+ const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID?.trim() ||
371
+ (creds.whatsappPhoneNumberId?.trim() ?? "");
372
+ const accessToken = process.env.WHATSAPP_MESSAGES_TOKEN?.trim() ||
373
+ (creds.whatsappCloudApiToken?.trim() ?? "");
374
+ if (phoneNumberId && accessToken) {
375
+ try {
376
+ const { jobId } = await enqueueJob({
377
+ jobType: "whatsappNotify",
378
+ payload: {
379
+ toPhone: resolvedPhone,
380
+ title,
381
+ message,
382
+ type,
383
+ templateName: resolveWhatsAppTemplateName(type, creds),
384
+ templateLanguage: creds.whatsappTemplateLanguage ?? "en",
385
+ notificationId: notification.id,
386
+ phoneNumberId,
387
+ accessToken,
388
+ },
389
+ requestedBy: input.userId,
390
+ });
391
+ whatsappStatus = "queued";
392
+ await notificationRepository
393
+ .update(notification.id, { whatsappStatus: "queued", whatsappJobId: jobId })
394
+ .catch((err) => {
395
+ void normalizeError(err);
396
+ serverLogger.warn("sendNotification: failed to write whatsappJobId onto notification doc", { notificationId: notification.id });
397
+ });
398
+ }
399
+ catch (err) {
400
+ void normalizeError(err);
401
+ serverLogger.error("sendNotification: WhatsApp job enqueue failed", { userId: input.userId, err });
402
+ whatsappStatus = "failed";
403
+ }
404
+ }
405
+ }
262
406
  serverLogger.info("sendNotification: dispatched", {
263
407
  userId: input.userId,
264
408
  type,
265
409
  email: emailStatus,
410
+ whatsapp: whatsappStatus,
266
411
  });
267
- return { notification, email: emailStatus };
412
+ return { notification, email: emailStatus, whatsapp: whatsappStatus };
268
413
  }
@@ -37,7 +37,7 @@ export interface AdminOrderEditorViewProps {
37
37
  paymentReviewOutcome?: string;
38
38
  /**
39
39
  * Paid add-ons + applied coupon for this order. Operational, not decorative:
40
- * `giftWrapAddon` tells the packer to wrap the parcel, and the
40
+ * `whatsappNotifyAddon` is who a status change should message, and the
41
41
  * coupon is what an admin needs when reconciling a discounted order.
42
42
  */
43
43
  addons?: OrderAddonBadgesOrder;
@@ -255,6 +255,8 @@ export function AdminOrdersView({ children, ...props }) {
255
255
  // Straight off the order document — the admin list route returns
256
256
  // raw docs, so these need no serializer entry (cf. Root Cause #38).
257
257
  addons: {
258
+ whatsappNotifyAddon: Boolean(item.whatsappNotifyAddon),
259
+ whatsappNotifyFee: Number(item.whatsappNotifyFee ?? 0) || undefined,
258
260
  giftWrapAddon: Boolean(item.giftWrapAddon),
259
261
  giftWrapFee: Number(item.giftWrapFee ?? 0) || undefined,
260
262
  giftWrapMessage: toStringValue(item.giftWrapMessage) || undefined,