@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.
- package/dist/.build-stamp.json +3 -3
- 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/orders/adapters.js +3 -1
- package/dist/_internal/server/features/site-settings/adapters.js +20 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +1 -0
- 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/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -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 +28 -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/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 +15 -0
- package/dist/constants/api-endpoints.js +8 -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/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.js +1 -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-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/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/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/homepage/lib/section-renderer.js +1 -1
- 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/StoreCapabilitiesGuideView.js +1 -0
- package/dist/features/stores/components/StoreGuideHubView.js +1 -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 +29 -1
- package/dist/features/stores/schemas/firestore.d.ts +16 -6
- package/dist/features/stores/schemas/firestore.js +3 -6
- package/dist/features/stores/schemas/index.d.ts +86 -0
- package/dist/features/stores/schemas/index.js +14 -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 +3 -0
- package/dist/next/routing/route-map.js +1 -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 +105 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server.d.ts +10 -2
- package/dist/server.js +22 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/NavigationLoader.d.ts +14 -1
- package/dist/ui/components/NavigationLoader.js +79 -30
- package/package.json +1 -1
- package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
- 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: "
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
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,
|