@mohasinac/appkit 4.35.0 → 4.35.2
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/_internal/server/features/checkout/actions.js +28 -22
- package/dist/_internal/server/features/orders/actions.js +58 -49
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +8 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
- package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
- package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
- package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
- package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
- package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
- package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
- package/dist/_internal/server/notifications/send-guard.js +142 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
- package/dist/_internal/shared/features/messaging/config.js +44 -0
- package/dist/_internal/shared/features/notifications/action-url.js +9 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
- package/dist/constants/api-endpoints.d.ts +30 -0
- package/dist/constants/api-endpoints.js +10 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/admin/actions/notification-actions.d.ts +20 -0
- package/dist/features/admin/actions/notification-actions.js +43 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
- package/dist/features/admin/schemas/firestore.d.ts +32 -2
- package/dist/features/admin/schemas/firestore.js +28 -0
- package/dist/features/auth/actions/profile-actions.d.ts +14 -1
- package/dist/features/auth/actions/profile-actions.js +16 -2
- package/dist/features/auth/hooks/useAuth.d.ts +5 -0
- package/dist/features/auth/hooks/useAuth.js +42 -0
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
- package/dist/features/contact/email.d.ts +30 -26
- package/dist/features/contact/email.js +65 -105
- package/dist/features/email/notification-templates.js +11 -0
- package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
- package/dist/features/messaging/repository/message-budget.repository.js +138 -0
- package/dist/features/messaging/schemas/firestore.d.ts +62 -0
- package/dist/features/messaging/schemas/firestore.js +36 -0
- package/dist/features/tester/seed-data/carts-tester-seed-data.d.ts +3 -0
- package/dist/features/tester/seed-data/carts-tester-seed-data.js +70 -0
- package/dist/features/tester/seed-data/index.d.ts +1 -0
- package/dist/features/tester/seed-data/index.js +1 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
- package/dist/seed/cart-seed-data.js +11 -0
- package/dist/seed/index.d.ts +1 -1
- package/dist/seed/index.js +1 -1
- package/dist/seed/manifest.js +2 -2
- package/dist/seed/notifications-seed-data.js +5 -1
- package/dist/seed/site-settings-seed-data.js +39 -10
- package/dist/server.d.ts +3 -5
- package/dist/server.js +27 -13
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +752 -752
|
@@ -306,7 +306,17 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
306
306
|
// Daily ops digest — recipients are edited as one-per-line text, split on save.
|
|
307
307
|
const [digestEnabled, setDigestEnabled] = React.useState(false);
|
|
308
308
|
const [digestRecipients, setDigestRecipients] = React.useState("");
|
|
309
|
-
|
|
309
|
+
/*
|
|
310
|
+
* Outbound messaging: the kill switch and the daily ceiling.
|
|
311
|
+
*
|
|
312
|
+
* `emailEnabled` suppresses USER-facing mail only. Staff mail — the daily
|
|
313
|
+
* digest, the payout summary — keeps flowing regardless, because switching
|
|
314
|
+
* off customer email must never also blind the operator.
|
|
315
|
+
*/
|
|
316
|
+
const [msgEmailEnabled, setMsgEmailEnabled] = React.useState(false);
|
|
317
|
+
const [msgWhatsappEnabled, setMsgWhatsappEnabled] = React.useState(false);
|
|
318
|
+
const [msgCeilingEmail, setMsgCeilingEmail] = React.useState(80);
|
|
319
|
+
const [msgCeilingWhatsapp, setMsgCeilingWhatsapp] = React.useState(200);
|
|
310
320
|
// Snapshot of the masked placeholder strings the server returned for every
|
|
311
321
|
// credentials.* field, captured once per load. A combined single-save has
|
|
312
322
|
// to send the FULL credentials object on every save (not just the group the
|
|
@@ -490,7 +500,13 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
490
500
|
setNotifFromName(s.emailSettings?.fromName ?? "");
|
|
491
501
|
setDigestEnabled(s.emailSettings?.dailyDigest?.enabled ?? false);
|
|
492
502
|
setDigestRecipients((s.emailSettings?.dailyDigest?.recipients ?? []).join("\n"));
|
|
493
|
-
|
|
503
|
+
// Defaults mirror MESSAGE_BUDGET_DEFAULTS — both channels OFF, so a
|
|
504
|
+
// settings document written before this feature existed reads as quiet
|
|
505
|
+
// rather than as "on and unmetered".
|
|
506
|
+
setMsgEmailEnabled(s.messaging?.emailEnabled ?? false);
|
|
507
|
+
setMsgWhatsappEnabled(s.messaging?.whatsappEnabled ?? false);
|
|
508
|
+
setMsgCeilingEmail(s.messaging?.dailyCeiling?.email ?? 80);
|
|
509
|
+
setMsgCeilingWhatsapp(s.messaging?.dailyCeiling?.whatsapp ?? 200);
|
|
494
510
|
originalMaskedRef.current = {
|
|
495
511
|
razorpayKeyId: s.credentialsMasked?.razorpayKeyId ?? "",
|
|
496
512
|
razorpayKeySecret: s.credentialsMasked?.razorpayKeySecret ?? "",
|
|
@@ -630,7 +646,21 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
630
646
|
dailyDigest: {
|
|
631
647
|
enabled: digestEnabled,
|
|
632
648
|
recipients: splitEmailList(digestRecipients),
|
|
633
|
-
|
|
649
|
+
// Always empty — the CC field is gone (see the Notifications tab).
|
|
650
|
+
// Sent explicitly rather than omitted so a save clears any CC list a
|
|
651
|
+
// pre-existing document still carries.
|
|
652
|
+
ccRecipients: [],
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
messaging: {
|
|
656
|
+
emailEnabled: msgEmailEnabled,
|
|
657
|
+
whatsappEnabled: msgWhatsappEnabled,
|
|
658
|
+
dailyCeiling: {
|
|
659
|
+
email: msgCeilingEmail,
|
|
660
|
+
whatsapp: msgCeilingWhatsapp,
|
|
661
|
+
// No SMS sender exists; the ceiling is carried so one cannot ship
|
|
662
|
+
// uncapped if somebody adds a provider later.
|
|
663
|
+
sms: 50,
|
|
634
664
|
},
|
|
635
665
|
},
|
|
636
666
|
integrations: { googleAnalyticsId: gaMeasurementId, facebookPixelId: fbPixelId, gtmContainerId },
|
|
@@ -744,7 +774,7 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
744
774
|
...bidIncrementTiers.slice(0, -1),
|
|
745
775
|
{ upTo: 100, increment: 10 },
|
|
746
776
|
bidIncrementTiers[bidIncrementTiers.length - 1] ?? { upTo: null, increment: 1000 },
|
|
747
|
-
]), children: "+ Add tier" })] }), _jsx(Input, { label: "Auto-extend window (minutes before end)", value: String(autoExtendWindow), onChange: (e) => setAutoExtendWindow(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Extend auction end time if a bid arrives within this window." }), _jsx(Input, { label: "Settlement grace period (hours)", value: String(settlementGrace), onChange: (e) => setSettlementGrace(parseInt(e.target.value) || 0), type: "number", min: 1, helperText: "Time winner has to pay before the auction is re-listed." })] }) }), _jsx(TabsContent, { value: "limits", children: _jsx(SettingsTabForm, { id: "limits", label: "Limits", children: _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Max products per store", value: String(maxProductsPerStore), onChange: (e) => setMaxProductsPerStore(parseInt(e.target.value) || 0), type: "number", min: 1 }), _jsx(Input, { label: "Max custom fields per product", value: String(maxCustomFields), onChange: (e) => setMaxCustomFields(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Max custom sections per product", value: String(maxCustomSections), onChange: (e) => setMaxCustomSections(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Order cancellation window (hours)", value: String(orderCancelWindow), onChange: (e) => setOrderCancelWindow(parseInt(e.target.value) || 0), type: "number", min: 0 })] }) }) }), _jsx(TabsContent, { value: "whatsapp", children: _jsxs(SettingsTabForm, { id: "whatsapp", label: "Whatsapp", children: [_jsx(Text, { size: "xs", color: "muted", children: "Platform-level WhatsApp Business Cloud API credentials. Used for automated purchase announcements to admin numbers when orders are placed. Store owners configure their own credentials in Store \u2192 WhatsApp." }), _jsx(Input, { label: "Phone Number ID", value: waPhoneNumberId, onChange: (e) => setWaPhoneNumberId(e.target.value), placeholder: "987654321098765", helperText: "From Meta for Developers \u2192 App \u2192 WhatsApp \u2192 API Setup" }), _jsx(MaskedInput, { label: "Cloud API System User Token", value: waCloudApiToken, onChange: setWaCloudApiToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived system user access token with WhatsApp Business permissions" }), _jsx(Input, { label: "Admin Notify Numbers", value: waAdminNotifyNumbers, onChange: (e) => setWaAdminNotifyNumbers(e.target.value), placeholder: "919876543210,918765432109", helperText: "Comma-separated, digits-only, include country code. These receive a WhatsApp message when any order is placed." }), _jsx(Text, { size: "xs", color: "muted", children: "Approved Meta message templates for the buyer-facing WhatsApp order-updates addon (Fees tab). Meta rejects business-initiated free-text WhatsApp messages outside a 24h customer-service window \u2014 a real template name here is required for that notification type to actually deliver. Leave blank to skip WhatsApp for that type (in-app + email still fire)." }), _jsx(Input, { label: "Template language code", value: waTemplateLanguage, onChange: (e) => setWaTemplateLanguage(e.target.value), placeholder: "en", helperText: "BCP-47 code the templates below were approved in, e.g. en or en_US." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Order placed template name", value: waTemplateOrderPlaced, onChange: (e) => setWaTemplateOrderPlaced(e.target.value), placeholder: "order_placed_update" }), _jsx(Input, { label: "Order confirmed template name", value: waTemplateOrderConfirmed, onChange: (e) => setWaTemplateOrderConfirmed(e.target.value), placeholder: "order_confirmed_update" }), _jsx(Input, { label: "Order shipped template name", value: waTemplateOrderShipped, onChange: (e) => setWaTemplateOrderShipped(e.target.value), placeholder: "order_shipped_update" }), _jsx(Input, { label: "Order delivered template name", value: waTemplateOrderDelivered, onChange: (e) => setWaTemplateOrderDelivered(e.target.value), placeholder: "order_delivered_update" }), _jsx(Input, { label: "Order cancelled template name", value: waTemplateOrderCancelled, onChange: (e) => setWaTemplateOrderCancelled(e.target.value), placeholder: "order_cancelled_update" }), _jsx(Input, { label: "Refund initiated template name", value: waTemplateRefundInitiated, onChange: (e) => setWaTemplateRefundInitiated(e.target.value), placeholder: "refund_initiated_update" })] })] }) }), _jsx(TabsContent, { value: "notifications", children: _jsxs(SettingsTabForm, { id: "notifications", label: "Notifications", children: [_jsx(Text, { size: "xs", color: "muted", children: "In-app notifications are always on. Enable external channels below to let the platform fan out to email, WhatsApp, or SMS. Users can further restrict which types they receive." }), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "md", children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "In-app (notification bell)" }), _jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-xs", children: "Always on" })] }), _jsx(Text, { size: "xs", color: "muted", children: "Displayed in the notification bell and inbox. Cannot be disabled." })] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-email-notifications", children: _jsx(Toggle, { label: "Email notifications", checked: notifEmailEnabled, onChange: setNotifEmailEnabled }) }), notifEmailEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send email", options: PRIORITY_OPTIONS, value: notifEmailMinPriority, onValueChange: setNotifEmailMinPriority }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Resend API (for transactional email)" }), _jsx(MaskedInput, { label: "Resend API Key", value: resendApiKey, onChange: setResendApiKey, placeholder: "re_live_\u2026", helperText: "Get your key at resend.com \u2014 used for all transactional notifications." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "From email", value: notifFromEmail, onChange: (e) => setNotifFromEmail(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Input, { label: "From name", value: notifFromName, onChange: (e) => setNotifFromName(e.target.value), placeholder: "LetItRip" })] }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifEmailTypes, onChange: (values) => setNotifEmailTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-daily-digest", children: _jsx(Toggle, { label: "Daily status digest", checked: digestEnabled, onChange: setDigestEnabled }) }), digestEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Text, { size: "xs", color: "muted", children: "Sent every morning at 10:00 IST with the previous 24 hours of order activity. Its arrival doubles as the platform health check \u2014 if it stops landing, something needs a look." }), _jsx(Textarea, { label: "Recipients (To)", value: digestRecipients, onChange: (e) => setDigestRecipients(e.target.value), placeholder: "support@letitrip.in\nmohasin@letitrip.in", rows: 4, helperText: "One address per line." }), _jsx(Textarea, { label: "CC recipients", value: digestCcRecipients, onChange: (e) => setDigestCcRecipients(e.target.value), placeholder: "One address per line", rows: 3, helperText: "Optional \u2014 anyone else who should receive a copy." })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-whatsapp-notifications", children: _jsx(Toggle, { label: "WhatsApp notifications", checked: notifWhatsappEnabled, onChange: setNotifWhatsappEnabled }) }), notifWhatsappEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send WhatsApp", options: PRIORITY_OPTIONS, value: notifWhatsappMinPriority, onValueChange: setNotifWhatsappMinPriority }), _jsx(Toggle, { label: "Enable WhatsApp OTP (for login and verification)", checked: notifWhatsappOtpEnabled, onChange: setNotifWhatsappOtpEnabled }), _jsx(Text, { size: "xs", color: "muted", children: "WhatsApp credentials are configured in the WhatsApp tab (\u246C). OTP messages use the same phone number." }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifWhatsappTypes, onChange: (values) => setNotifWhatsappTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Toggle, { label: "SMS notifications", checked: notifSmsEnabled, onChange: setNotifSmsEnabled }), notifSmsEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send SMS", options: PRIORITY_OPTIONS, value: notifSmsMinPriority, onValueChange: setNotifSmsMinPriority }), _jsx(Text, { size: "xs", color: "muted", children: "SMS gateway credentials (e.g. Twilio, MSG91) can be configured in the Integrations tab once an SMS provider is connected." })] }))] })] }) }), _jsx(TabsContent, { value: "legal", children: _jsx(SettingsTabForm, { id: "legal", label: "Legal", children: [
|
|
777
|
+
]), children: "+ Add tier" })] }), _jsx(Input, { label: "Auto-extend window (minutes before end)", value: String(autoExtendWindow), onChange: (e) => setAutoExtendWindow(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Extend auction end time if a bid arrives within this window." }), _jsx(Input, { label: "Settlement grace period (hours)", value: String(settlementGrace), onChange: (e) => setSettlementGrace(parseInt(e.target.value) || 0), type: "number", min: 1, helperText: "Time winner has to pay before the auction is re-listed." })] }) }), _jsx(TabsContent, { value: "limits", children: _jsx(SettingsTabForm, { id: "limits", label: "Limits", children: _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Max products per store", value: String(maxProductsPerStore), onChange: (e) => setMaxProductsPerStore(parseInt(e.target.value) || 0), type: "number", min: 1 }), _jsx(Input, { label: "Max custom fields per product", value: String(maxCustomFields), onChange: (e) => setMaxCustomFields(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Max custom sections per product", value: String(maxCustomSections), onChange: (e) => setMaxCustomSections(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Order cancellation window (hours)", value: String(orderCancelWindow), onChange: (e) => setOrderCancelWindow(parseInt(e.target.value) || 0), type: "number", min: 0 })] }) }) }), _jsx(TabsContent, { value: "whatsapp", children: _jsxs(SettingsTabForm, { id: "whatsapp", label: "Whatsapp", children: [_jsx(Text, { size: "xs", color: "muted", children: "Platform-level WhatsApp Business Cloud API credentials. Used for automated purchase announcements to admin numbers when orders are placed. Store owners configure their own credentials in Store \u2192 WhatsApp." }), _jsx(Input, { label: "Phone Number ID", value: waPhoneNumberId, onChange: (e) => setWaPhoneNumberId(e.target.value), placeholder: "987654321098765", helperText: "From Meta for Developers \u2192 App \u2192 WhatsApp \u2192 API Setup" }), _jsx(MaskedInput, { label: "Cloud API System User Token", value: waCloudApiToken, onChange: setWaCloudApiToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived system user access token with WhatsApp Business permissions" }), _jsx(Input, { label: "Admin Notify Numbers", value: waAdminNotifyNumbers, onChange: (e) => setWaAdminNotifyNumbers(e.target.value), placeholder: "919876543210,918765432109", helperText: "Comma-separated, digits-only, include country code. These receive a WhatsApp message when any order is placed." }), _jsx(Text, { size: "xs", color: "muted", children: "Approved Meta message templates for the buyer-facing WhatsApp order-updates addon (Fees tab). Meta rejects business-initiated free-text WhatsApp messages outside a 24h customer-service window \u2014 a real template name here is required for that notification type to actually deliver. Leave blank to skip WhatsApp for that type (in-app + email still fire)." }), _jsx(Input, { label: "Template language code", value: waTemplateLanguage, onChange: (e) => setWaTemplateLanguage(e.target.value), placeholder: "en", helperText: "BCP-47 code the templates below were approved in, e.g. en or en_US." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Order placed template name", value: waTemplateOrderPlaced, onChange: (e) => setWaTemplateOrderPlaced(e.target.value), placeholder: "order_placed_update" }), _jsx(Input, { label: "Order confirmed template name", value: waTemplateOrderConfirmed, onChange: (e) => setWaTemplateOrderConfirmed(e.target.value), placeholder: "order_confirmed_update" }), _jsx(Input, { label: "Order shipped template name", value: waTemplateOrderShipped, onChange: (e) => setWaTemplateOrderShipped(e.target.value), placeholder: "order_shipped_update" }), _jsx(Input, { label: "Order delivered template name", value: waTemplateOrderDelivered, onChange: (e) => setWaTemplateOrderDelivered(e.target.value), placeholder: "order_delivered_update" }), _jsx(Input, { label: "Order cancelled template name", value: waTemplateOrderCancelled, onChange: (e) => setWaTemplateOrderCancelled(e.target.value), placeholder: "order_cancelled_update" }), _jsx(Input, { label: "Refund initiated template name", value: waTemplateRefundInitiated, onChange: (e) => setWaTemplateRefundInitiated(e.target.value), placeholder: "refund_initiated_update" })] })] }) }), _jsx(TabsContent, { value: "notifications", children: _jsxs(SettingsTabForm, { id: "notifications", label: "Notifications", children: [_jsx(Text, { size: "xs", color: "muted", children: "In-app notifications are always on. Enable external channels below to let the platform fan out to email, WhatsApp, or SMS. Users can further restrict which types they receive." }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Outbound messaging budget" }), _jsxs(Text, { size: "xs", color: "muted", children: ["The master switches. These suppress ", _jsx(Span, { weight: "semibold", children: "user-facing" }), " messages only \u2014 the daily status digest always reaches staff, so turning customer email off never leaves the site unwatched. Transactional messages (checkout verification codes, order receipts) also keep sending, because suppressing those breaks checkout rather than quietening it."] }), _jsx(Toggle, { label: "Send user-facing email", checked: msgEmailEnabled, onChange: setMsgEmailEnabled }), _jsx(Toggle, { label: "Send user-facing WhatsApp", checked: msgWhatsappEnabled, onChange: setMsgWhatsappEnabled }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Daily email ceiling", type: "number", value: String(msgCeilingEmail), onChange: (e) => setMsgCeilingEmail(Number(e.target.value) || 0), helperText: "Resend's free tier is 100/day. 80 leaves headroom for password-reset mail and in-flight sends." }), _jsx(Input, { label: "Daily WhatsApp ceiling", type: "number", value: String(msgCeilingWhatsapp), onChange: (e) => setMsgCeilingWhatsapp(Number(e.target.value) || 0), helperText: "A sanity bound. Meta bills per conversation, not per message." })] }), _jsx(Text, { size: "xs", color: "muted", children: "Some notification types never email regardless of these settings \u2014 losing an auction, being outbid, marketing, review activity and ticket updates are shown in the notification bell only. That is deliberate: those were the highest-volume sends and the ones most likely to exhaust the day's allowance. A support reply can still be emailed one-off from the ticket screen." })] }), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "md", children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "In-app (notification bell)" }), _jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-xs", children: "Always on" })] }), _jsx(Text, { size: "xs", color: "muted", children: "Displayed in the notification bell and inbox. Cannot be disabled." })] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-email-notifications", children: _jsx(Toggle, { label: "Email notifications", checked: notifEmailEnabled, onChange: setNotifEmailEnabled }) }), notifEmailEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send email", options: PRIORITY_OPTIONS, value: notifEmailMinPriority, onValueChange: setNotifEmailMinPriority }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Resend API (for transactional email)" }), _jsx(MaskedInput, { label: "Resend API Key", value: resendApiKey, onChange: setResendApiKey, placeholder: "re_live_\u2026", helperText: "Get your key at resend.com \u2014 used for all transactional notifications." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "From email", value: notifFromEmail, onChange: (e) => setNotifFromEmail(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Input, { label: "From name", value: notifFromName, onChange: (e) => setNotifFromName(e.target.value), placeholder: "LetItRip" })] }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifEmailTypes, onChange: (values) => setNotifEmailTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-daily-digest", children: _jsx(Toggle, { label: "Daily status digest", checked: digestEnabled, onChange: setDigestEnabled }) }), digestEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Text, { size: "xs", color: "muted", children: "Sent every morning at 10:00 IST with the previous 24 hours of order activity. Its arrival doubles as the platform health check \u2014 if it stops landing, something needs a look." }), _jsx(Textarea, { label: "Recipients (To)", value: digestRecipients, onChange: (e) => setDigestRecipients(e.target.value), placeholder: "mohasin@letitrip.in", rows: 3, helperText: "One address per line. Each address is billed as a separate send against the daily allowance \u2014 keep this list short." })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-whatsapp-notifications", children: _jsx(Toggle, { label: "WhatsApp notifications", checked: notifWhatsappEnabled, onChange: setNotifWhatsappEnabled }) }), notifWhatsappEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send WhatsApp", options: PRIORITY_OPTIONS, value: notifWhatsappMinPriority, onValueChange: setNotifWhatsappMinPriority }), _jsx(Toggle, { label: "Enable WhatsApp OTP (for login and verification)", checked: notifWhatsappOtpEnabled, onChange: setNotifWhatsappOtpEnabled }), _jsx(Text, { size: "xs", color: "muted", children: "WhatsApp credentials are configured in the WhatsApp tab (\u246C). OTP messages use the same phone number." }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifWhatsappTypes, onChange: (values) => setNotifWhatsappTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Toggle, { label: "SMS notifications", checked: notifSmsEnabled, onChange: setNotifSmsEnabled }), notifSmsEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send SMS", options: PRIORITY_OPTIONS, value: notifSmsMinPriority, onValueChange: setNotifSmsMinPriority }), _jsx(Text, { size: "xs", color: "muted", children: "SMS gateway credentials (e.g. Twilio, MSG91) can be configured in the Integrations tab once an SMS provider is connected." })] }))] })] }) }), _jsx(TabsContent, { value: "legal", children: _jsx(SettingsTabForm, { id: "legal", label: "Legal", children: [
|
|
748
778
|
["Terms of Service", termsHtml, setTermsHtml],
|
|
749
779
|
["Privacy Policy", privacyHtml, setPrivacyHtml],
|
|
750
780
|
["Refund Policy", refundHtml, setRefundHtml],
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { Code, useApiMutation } from "@mohasinac/appkit/client";
|
|
4
4
|
import React from "react";
|
|
5
5
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
-
import { Button, Div, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
|
|
6
|
+
import { Button, Checkbox, Div, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
|
|
7
7
|
import { apiClient } from "../../../http";
|
|
8
8
|
import { ADMIN_ENDPOINTS, SUPPORT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
9
9
|
import { supportTicketUpdateSchema, toRelatedPartiesPayload, } from "../schemas/admin-ops-forms";
|
|
@@ -65,6 +65,12 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
|
|
|
65
65
|
const queryClient = useQueryClient();
|
|
66
66
|
const { showToast } = useToast();
|
|
67
67
|
const [replyBody, setReplyBody] = React.useState("");
|
|
68
|
+
/*
|
|
69
|
+
* Opt-in per reply, and it RESETS to false after every send (see
|
|
70
|
+
* `onSuccess`). A sticky "also email" would quietly turn the exception back
|
|
71
|
+
* into the default — which is the behaviour this whole change removed.
|
|
72
|
+
*/
|
|
73
|
+
const [alsoEmail, setAlsoEmail] = React.useState(false);
|
|
68
74
|
const [draft, setDraft] = React.useState(() => seedTicketDraft(currentStatus, currentPriority, internalNotes, relatedParties));
|
|
69
75
|
React.useEffect(() => {
|
|
70
76
|
if (open) {
|
|
@@ -102,11 +108,12 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
|
|
|
102
108
|
const replyMutation = useApiMutation({
|
|
103
109
|
errorMessage: "Failed to send reply.",
|
|
104
110
|
mutationFn: async () => {
|
|
105
|
-
await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(ticketId), { body: replyBody, newStatus: draft.status });
|
|
111
|
+
await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(ticketId), { body: replyBody, newStatus: draft.status, sendEmail: alsoEmail });
|
|
106
112
|
},
|
|
107
113
|
onSuccess: () => {
|
|
108
|
-
showToast("Reply sent.", "success");
|
|
114
|
+
showToast(alsoEmail ? "Reply sent and emailed." : "Reply sent.", "success");
|
|
109
115
|
setReplyBody("");
|
|
116
|
+
setAlsoEmail(false);
|
|
110
117
|
invalidate();
|
|
111
118
|
onClose();
|
|
112
119
|
},
|
|
@@ -257,7 +264,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
|
|
|
257
264
|
setOrderItemsOpen(false);
|
|
258
265
|
},
|
|
259
266
|
});
|
|
260
|
-
return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
|
|
267
|
+
return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Checkbox, { checked: alsoEmail, onChange: (e) => setAlsoEmail(e.target.checked), label: "Also email the user about this reply" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
|
|
261
268
|
// SectionForm scrolls to the first erroring section and then
|
|
262
269
|
// submits regardless, so the guard lives here.
|
|
263
270
|
form.clearErrors();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { AboutContentDocument } from "../../about/schemas/firestore";
|
|
6
6
|
export declare const ADMIN_CHECKOUT_BYPASS_FLAG_KEY: "adminCheckoutBypass";
|
|
7
|
-
export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
|
|
7
|
+
export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
|
|
8
8
|
export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
|
|
9
9
|
/**
|
|
10
10
|
* What a notification points AT. Drives the shared `actionUrl` resolver, so a
|
|
@@ -77,7 +77,7 @@ export type NotificationUpdateInput = Partial<Pick<NotificationDocument, "isRead
|
|
|
77
77
|
export declare const notificationQueryHelpers: {
|
|
78
78
|
readonly byUser: (userId: string) => readonly ["userId", "==", string];
|
|
79
79
|
readonly unread: (userId: string) => readonly [readonly ["userId", "==", string], readonly ["isRead", "==", false]];
|
|
80
|
-
readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "account_action" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale"];
|
|
80
|
+
readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "account_action" | "support_ticket_update" | "scam_report_update" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale"];
|
|
81
81
|
};
|
|
82
82
|
export interface TrustBarItem {
|
|
83
83
|
icon: string;
|
|
@@ -447,6 +447,36 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
447
447
|
instagram?: string;
|
|
448
448
|
linkedin?: string;
|
|
449
449
|
};
|
|
450
|
+
/**
|
|
451
|
+
* The outbound-messaging kill switch and spend ceiling.
|
|
452
|
+
*
|
|
453
|
+
* Distinct from `notificationChannels`, which decides WHICH notification
|
|
454
|
+
* types a channel carries. This decides whether the channel runs at all and
|
|
455
|
+
* how much of the day's allowance it may spend — a site-wide posture rather
|
|
456
|
+
* than a per-type routing rule.
|
|
457
|
+
*
|
|
458
|
+
* 🛑 Every field here is PRIVATE. It is triaged into
|
|
459
|
+
* `PRIVATE_SITE_SETTINGS_FIELDS` in the public projection, and must stay
|
|
460
|
+
* there: the ceiling and the remaining headroom are operational detail, and
|
|
461
|
+
* publishing "email is currently off" tells an attacker that password-reset
|
|
462
|
+
* mail is not being watched.
|
|
463
|
+
*/
|
|
464
|
+
messaging?: {
|
|
465
|
+
/** User-facing email. Staff mail (daily digest, payout summary) ignores this. */
|
|
466
|
+
emailEnabled: boolean;
|
|
467
|
+
/** User-facing WhatsApp. Same exemption for staff. */
|
|
468
|
+
whatsappEnabled: boolean;
|
|
469
|
+
/**
|
|
470
|
+
* Reserved sends allowed per channel per day. Applies to staff and
|
|
471
|
+
* transactional mail too — the ceiling exists to stay inside the
|
|
472
|
+
* provider's own limit, and the provider does not exempt anyone.
|
|
473
|
+
*/
|
|
474
|
+
dailyCeiling: {
|
|
475
|
+
email: number;
|
|
476
|
+
whatsapp: number;
|
|
477
|
+
sms: number;
|
|
478
|
+
};
|
|
479
|
+
};
|
|
450
480
|
emailSettings: {
|
|
451
481
|
fromName: string;
|
|
452
482
|
fromEmail: string;
|
|
@@ -49,7 +49,27 @@ export const NOTIFICATION_TYPE_VALUES = [
|
|
|
49
49
|
"promotion",
|
|
50
50
|
"system",
|
|
51
51
|
"welcome",
|
|
52
|
+
/*
|
|
53
|
+
* 🛑 `account_action` means A BAN OR AN UNBAN, and nothing else.
|
|
54
|
+
*
|
|
55
|
+
* It used to also carry support-ticket activity AND scam-report outcomes —
|
|
56
|
+
* three unrelated concerns on one value, which made them impossible to tell
|
|
57
|
+
* apart anywhere downstream. That mattered the moment email became
|
|
58
|
+
* type-gated (see `EMAIL_ELIGIBLE_TYPES`): there was no way to stop emailing
|
|
59
|
+
* "your ticket got a reply" without also silencing "you have been banned",
|
|
60
|
+
* and those two have opposite answers. A banned user has to be told outside
|
|
61
|
+
* the app, because the app is what they just lost.
|
|
62
|
+
*
|
|
63
|
+
* Split 2026-09. If you are tempted to route a fourth concern through this
|
|
64
|
+
* value because it is vaguely account-shaped, add a type instead — the
|
|
65
|
+
* `Record<NotificationType, …>` maps will tell you everywhere that needs an
|
|
66
|
+
* answer.
|
|
67
|
+
*/
|
|
52
68
|
"account_action",
|
|
69
|
+
/** Support ticket created, replied to, or moved status. Bell-only by default — see `EMAIL_ELIGIBLE_TYPES`; staff can opt a single reply into email. */
|
|
70
|
+
"support_ticket_update",
|
|
71
|
+
/** Scam report received, verified, or rejected. Bell-only; the admin side rides the daily digest. */
|
|
72
|
+
"scam_report_update",
|
|
53
73
|
"offer_received",
|
|
54
74
|
"offer_responded",
|
|
55
75
|
"offer_expired",
|
|
@@ -199,6 +219,14 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
199
219
|
smsVerification: false,
|
|
200
220
|
adminCheckoutBypass: false,
|
|
201
221
|
},
|
|
222
|
+
// Mirrors MESSAGE_BUDGET_DEFAULTS — both channels start OFF. A fresh install
|
|
223
|
+
// that has made no decision should be quiet, not mailing real people from a
|
|
224
|
+
// half-configured site.
|
|
225
|
+
messaging: {
|
|
226
|
+
emailEnabled: false,
|
|
227
|
+
whatsappEnabled: false,
|
|
228
|
+
dailyCeiling: { email: 80, whatsapp: 200, sms: 50 },
|
|
229
|
+
},
|
|
202
230
|
emi: {
|
|
203
231
|
enabled: false,
|
|
204
232
|
minOrderValue: 10000,
|
|
@@ -39,7 +39,20 @@ export declare function getUserSessions(userId: string): Promise<import("..").Se
|
|
|
39
39
|
* including ones inside sent notification emails.
|
|
40
40
|
*/
|
|
41
41
|
export declare function resolveProfileUser(identifier: string): Promise<UserDocument | null>;
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* 🛑 React.cache-wrapped because a page and its generateMetadata both read it.
|
|
44
|
+
*
|
|
45
|
+
* `resolveProfileUser` tries up to THREE lookups (slug, id, uid), and
|
|
46
|
+
* `profile/[userId]/page.tsx` called this once in generateMetadata and again in
|
|
47
|
+
* the page body — so an uncached profile view cost up to six Firestore reads to
|
|
48
|
+
* answer one question. `sellers/[id]` had the same pair.
|
|
49
|
+
*
|
|
50
|
+
* This is the rule the SSR guide already states: every data-fetch exposed to a
|
|
51
|
+
* page + generateMetadata pair is memoised per argument list, so the second call
|
|
52
|
+
* is free. Note it caches the NARROW public projection, which is what both
|
|
53
|
+
* callers want; anything needing the whole document still goes to the repository.
|
|
54
|
+
*/
|
|
55
|
+
export declare const getPublicUserProfile: (userId: string) => Promise<Pick<UserDocument, "id" | "slug" | "displayName" | "photoURL" | "role" | "createdAt" | "storeId" | "storeSlug" | "publicProfile" | "stats"> | null>;
|
|
43
56
|
/** Fetch approved reviews for a store. storeId === storeSlug in this project. */
|
|
44
57
|
export declare function getSellerReviews(storeId: string): Promise<Review[]>;
|
|
45
58
|
export declare function getProfileStoreProducts(storeId: string): Promise<import("../../products").ProductDocument[]>;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Pure business functions for profile CRUD, seller stats, and session reads.
|
|
5
5
|
* Auth, rate-limiting, and Next.js specifics are handled by the consumer.
|
|
6
6
|
*/
|
|
7
|
+
import { cache } from "react";
|
|
7
8
|
import { maskPublicReview } from "../../../security";
|
|
8
9
|
import { safeRead } from "../../../errors/safe-read";
|
|
9
10
|
import { finalizeStagedMediaField } from "../../media/finalize";
|
|
@@ -47,7 +48,20 @@ export async function resolveProfileUser(identifier) {
|
|
|
47
48
|
return byId;
|
|
48
49
|
return userRepository.findByUid(identifier);
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
/**
|
|
52
|
+
* 🛑 React.cache-wrapped because a page and its generateMetadata both read it.
|
|
53
|
+
*
|
|
54
|
+
* `resolveProfileUser` tries up to THREE lookups (slug, id, uid), and
|
|
55
|
+
* `profile/[userId]/page.tsx` called this once in generateMetadata and again in
|
|
56
|
+
* the page body — so an uncached profile view cost up to six Firestore reads to
|
|
57
|
+
* answer one question. `sellers/[id]` had the same pair.
|
|
58
|
+
*
|
|
59
|
+
* This is the rule the SSR guide already states: every data-fetch exposed to a
|
|
60
|
+
* page + generateMetadata pair is memoised per argument list, so the second call
|
|
61
|
+
* is free. Note it caches the NARROW public projection, which is what both
|
|
62
|
+
* callers want; anything needing the whole document still goes to the repository.
|
|
63
|
+
*/
|
|
64
|
+
export const getPublicUserProfile = cache(async function getPublicUserProfile(userId) {
|
|
51
65
|
const user = await resolveProfileUser(userId);
|
|
52
66
|
if (!user)
|
|
53
67
|
return null;
|
|
@@ -63,7 +77,7 @@ export async function getPublicUserProfile(userId) {
|
|
|
63
77
|
publicProfile: user.publicProfile,
|
|
64
78
|
stats: user.stats,
|
|
65
79
|
};
|
|
66
|
-
}
|
|
80
|
+
});
|
|
67
81
|
/** Fetch approved reviews for a store. storeId === storeSlug in this project. */
|
|
68
82
|
export async function getSellerReviews(storeId) {
|
|
69
83
|
const snapshot = await safeRead(() => reviewRepository.findApprovedByStore(storeId), { route: "/profile/[userId]", key: "reviews.findApprovedByStore", fallback: [] });
|
|
@@ -100,6 +100,11 @@ export declare function useResendVerification(options?: {
|
|
|
100
100
|
onSuccess?: (data: JsonValue) => void;
|
|
101
101
|
onError?: (error: Error) => void;
|
|
102
102
|
}): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, void | ResendVerificationData, unknown>;
|
|
103
|
+
/** Thrown when the cooldown refuses — carries the wait so the UI can say it. */
|
|
104
|
+
export declare class AuthMailCooldownError extends Error {
|
|
105
|
+
readonly retryAfterSeconds: number;
|
|
106
|
+
constructor(retryAfterSeconds: number);
|
|
107
|
+
}
|
|
103
108
|
export declare function useForgotPassword(options?: {
|
|
104
109
|
onSuccess?: (data: JsonValue) => void;
|
|
105
110
|
onError?: (error: Error) => void;
|
|
@@ -373,9 +373,51 @@ export function useResendVerification(options) {
|
|
|
373
373
|
onError: options?.onError,
|
|
374
374
|
});
|
|
375
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Ask the server whether this address may be sent an auth mail right now.
|
|
378
|
+
*
|
|
379
|
+
* Firebase's own auth mail goes browser → Firebase with no server hop, so
|
|
380
|
+
* nothing on our side can see it — including `applyRateLimit`. This gate is the
|
|
381
|
+
* only throttle these flows have, and it is deliberately advisory: a `false`
|
|
382
|
+
* from the network, a thrown request, or a missing route all resolve to
|
|
383
|
+
* "allowed", because a user locked out of their account must never be blocked
|
|
384
|
+
* from requesting a reset by our own infrastructure failing.
|
|
385
|
+
*
|
|
386
|
+
* See `src/app/api/auth/mail-gate/route.ts` for why this is an abuse guard
|
|
387
|
+
* rather than a security boundary.
|
|
388
|
+
*/
|
|
389
|
+
async function requestAuthMailPermission(email, purpose) {
|
|
390
|
+
try {
|
|
391
|
+
const res = await apiClient.post(AUTH_ENDPOINTS.MAIL_GATE, { email, purpose });
|
|
392
|
+
const data = (res?.data ?? res);
|
|
393
|
+
return {
|
|
394
|
+
allowed: data?.allowed !== false,
|
|
395
|
+
retryAfterSeconds: data?.retryAfterSeconds ?? 0,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
// Normalised then deliberately discarded: an unreachable gate is not an
|
|
400
|
+
// incident worth reporting, it is the documented fail-open path. The user
|
|
401
|
+
// still gets their reset mail; only the cooldown is skipped.
|
|
402
|
+
void normalizeError(err);
|
|
403
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** Thrown when the cooldown refuses — carries the wait so the UI can say it. */
|
|
407
|
+
export class AuthMailCooldownError extends Error {
|
|
408
|
+
constructor(retryAfterSeconds) {
|
|
409
|
+
const mins = Math.max(1, Math.ceil(retryAfterSeconds / 60));
|
|
410
|
+
super(`We've already sent that recently — please check your inbox, including spam. You can request another in ${mins} minute${mins === 1 ? "" : "s"}.`);
|
|
411
|
+
this.name = "AuthMailCooldownError";
|
|
412
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
376
415
|
export function useForgotPassword(options) {
|
|
377
416
|
return useMutation({
|
|
378
417
|
mutationFn: async (data) => {
|
|
418
|
+
const gate = await requestAuthMailPermission(data.email, "password_reset");
|
|
419
|
+
if (!gate.allowed)
|
|
420
|
+
throw new AuthMailCooldownError(gate.retryAfterSeconds);
|
|
379
421
|
await getClientAuthProvider().sendPasswordResetEmail(data.email);
|
|
380
422
|
return { success: true };
|
|
381
423
|
},
|
|
@@ -18,6 +18,7 @@ import { addressesRepository } from "../../addresses/repository/addresses.reposi
|
|
|
18
18
|
import { siteSettingsRepository } from "../../admin/repository/site-settings.repository";
|
|
19
19
|
import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp";
|
|
20
20
|
import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health";
|
|
21
|
+
import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard";
|
|
21
22
|
import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp";
|
|
22
23
|
function maskEmail(userEmail) {
|
|
23
24
|
const [local, domain] = userEmail.split("@");
|
|
@@ -82,6 +83,20 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
|
|
|
82
83
|
createdAt: new Date(),
|
|
83
84
|
});
|
|
84
85
|
const message = buildCheckoutValueOtpMessage(code, siteName);
|
|
86
|
+
// Same guard as the email branch below, same reasoning: transactional, so
|
|
87
|
+
// the kill switch does not apply (the buyer cannot check out without the
|
|
88
|
+
// code), but the daily ceiling does.
|
|
89
|
+
const waGuard = {
|
|
90
|
+
channel: "whatsapp",
|
|
91
|
+
feature: "checkout_value_otp",
|
|
92
|
+
audience: "user",
|
|
93
|
+
transactional: true,
|
|
94
|
+
};
|
|
95
|
+
const waDecision = await guardSend(waGuard);
|
|
96
|
+
if (!waDecision.allow) {
|
|
97
|
+
logSuppressedSend(waGuard, waDecision);
|
|
98
|
+
throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
|
|
99
|
+
}
|
|
85
100
|
try {
|
|
86
101
|
const sent = await withChannelRetry("whatsapp", async () => {
|
|
87
102
|
const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
|
|
@@ -126,6 +141,23 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
|
|
|
126
141
|
<p style="color:#888;font-size:12px">This code expires in 10 minutes. If you did not request this, please ignore this email.</p>
|
|
127
142
|
</div>
|
|
128
143
|
`,
|
|
144
|
+
}, {
|
|
145
|
+
channel: "email",
|
|
146
|
+
feature: "checkout_value_otp",
|
|
147
|
+
audience: "user",
|
|
148
|
+
/*
|
|
149
|
+
* The strongest case for `transactional` in the codebase: the buyer
|
|
150
|
+
* is mid-checkout above the high-value threshold and CANNOT complete
|
|
151
|
+
* the order without this code. Suppressing it does not quieten the
|
|
152
|
+
* site, it breaks checkout — so it ignores the kill switch.
|
|
153
|
+
*
|
|
154
|
+
* It still counts against the daily ceiling, which is the honest
|
|
155
|
+
* behaviour: if the provider is going to start rejecting, pretending
|
|
156
|
+
* otherwise here just moves the failure somewhere less legible. The
|
|
157
|
+
* 15-minute per-user cooldown in `checkout-value-otp.ts` already
|
|
158
|
+
* bounds how much of the allowance one buyer can consume.
|
|
159
|
+
*/
|
|
160
|
+
transactional: true,
|
|
129
161
|
});
|
|
130
162
|
if (error)
|
|
131
163
|
throw new Error(String(error));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "server-only";
|
|
2
|
+
import type { SendGuardContext } from "../../_internal/shared/features/messaging/config";
|
|
2
3
|
import type { JsonValue } from "@mohasinac/appkit";
|
|
3
4
|
type SendEmailOptions = Record<string, JsonValue> & {
|
|
4
5
|
to: string | string[];
|
|
@@ -9,17 +10,37 @@ type SendEmailOptions = Record<string, JsonValue> & {
|
|
|
9
10
|
cc?: string | string[];
|
|
10
11
|
replyTo?: string;
|
|
11
12
|
};
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Everything `sendEmail` needs beyond the message itself.
|
|
15
|
+
*
|
|
16
|
+
* 🛑 REQUIRED, and that is the entire enforcement mechanism.
|
|
17
|
+
*
|
|
18
|
+
* There is no `guard?:` and no default. A sender that cannot say who this mail
|
|
19
|
+
* is for and what it is does not compile, so "someone forgot to guard a new
|
|
20
|
+
* send path" is not a class of bug that can occur — which is what a
|
|
21
|
+
* suppression-marker-and-audit approach would only have made *detectable*
|
|
22
|
+
* (CLAUDE.md Rule #22: make the wrong thing unrepresentable).
|
|
23
|
+
*
|
|
24
|
+
* Kept as a separate second parameter rather than a field on
|
|
25
|
+
* `SendEmailOptions` because that type has a `Record<string, JsonValue>` index
|
|
26
|
+
* signature — a structured object would not satisfy it, and widening the index
|
|
27
|
+
* signature to accommodate one field would weaken every other field's typing.
|
|
28
|
+
*/
|
|
29
|
+
export type SendEmailGuard = SendGuardContext;
|
|
30
|
+
/**
|
|
31
|
+
* Send one email, subject to the messaging guard.
|
|
32
|
+
*
|
|
33
|
+
* `suppressed` is a THIRD outcome, distinct from both success and failure, and
|
|
34
|
+
* callers must treat it as such: `{ data: null, error: null }` means the
|
|
35
|
+
* message was deliberately not sent, so a caller that only checks `error` will
|
|
36
|
+
* correctly not raise an alarm, and one that wants to know can read
|
|
37
|
+
* `suppressed`. Returning an `error` for a suppression would have turned every
|
|
38
|
+
* intentional quiet into a logged incident.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sendEmail(opts: SendEmailOptions, guard: SendEmailGuard): Promise<{
|
|
13
41
|
data: JsonValue;
|
|
14
42
|
error: JsonValue;
|
|
15
|
-
|
|
16
|
-
export declare function sendVerificationEmailWithLink(email: string, verificationLink: string): Promise<{
|
|
17
|
-
success: boolean;
|
|
18
|
-
data?: JsonValue;
|
|
19
|
-
}>;
|
|
20
|
-
export declare function sendPasswordResetEmailWithLink(email: string, resetLink: string): Promise<{
|
|
21
|
-
success: boolean;
|
|
22
|
-
data?: JsonValue;
|
|
43
|
+
suppressed?: string;
|
|
23
44
|
}>;
|
|
24
45
|
export interface OrderConfirmationEmailParams {
|
|
25
46
|
to: string;
|
|
@@ -43,15 +64,6 @@ export declare function sendOrderConfirmationEmail(params: OrderConfirmationEmai
|
|
|
43
64
|
success: boolean;
|
|
44
65
|
data?: JsonValue;
|
|
45
66
|
}>;
|
|
46
|
-
export declare function sendContactEmail(params: {
|
|
47
|
-
name: string;
|
|
48
|
-
email: string;
|
|
49
|
-
subject: string;
|
|
50
|
-
message: string;
|
|
51
|
-
}): Promise<{
|
|
52
|
-
success: boolean;
|
|
53
|
-
data?: JsonValue;
|
|
54
|
-
}>;
|
|
55
67
|
export declare function sendDigitalCodeClaimedEmail(params: {
|
|
56
68
|
to: string;
|
|
57
69
|
userName: string;
|
|
@@ -60,12 +72,4 @@ export declare function sendDigitalCodeClaimedEmail(params: {
|
|
|
60
72
|
}): Promise<{
|
|
61
73
|
success: boolean;
|
|
62
74
|
}>;
|
|
63
|
-
export declare function sendSiteSettingsChangedEmail(params: {
|
|
64
|
-
adminEmails: string[];
|
|
65
|
-
changedByEmail: string;
|
|
66
|
-
changedFields: string[];
|
|
67
|
-
}): Promise<{
|
|
68
|
-
success: boolean;
|
|
69
|
-
data?: JsonValue;
|
|
70
|
-
}>;
|
|
71
75
|
export {};
|