@mohasinac/appkit 4.35.1 → 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/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/tester-checklist-seed-data.js +277 -9
- 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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vocabulary for "may this message go out, and does the budget allow
|
|
3
|
+
* it" — the types both the server guard and the settings schema speak.
|
|
4
|
+
*
|
|
5
|
+
* Lives in `_internal/shared/` because the channel/audience unions are read on
|
|
6
|
+
* both sides: the guard enforces them server-side, the admin settings view
|
|
7
|
+
* renders them.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A metered outbound channel.
|
|
11
|
+
*
|
|
12
|
+
* `sms` is declared and has no sender. That is deliberate rather than
|
|
13
|
+
* aspirational: `notificationChannels.sms` has existed in the settings schema
|
|
14
|
+
* (and been seeded `enabled: true`) for a long time while no code has ever
|
|
15
|
+
* read it, so anyone adding an SMS provider will reach for this union first
|
|
16
|
+
* and find the budget already covers them. A channel that is born capped
|
|
17
|
+
* cannot repeat what email did.
|
|
18
|
+
*/
|
|
19
|
+
export type MessageChannel = "email" | "whatsapp" | "sms";
|
|
20
|
+
/**
|
|
21
|
+
* Who the message is FOR — which is what the kill switch keys on.
|
|
22
|
+
*
|
|
23
|
+
* `user` is a buyer or seller receiving something about their own activity.
|
|
24
|
+
* `staff` is the operators of the site receiving something about the site.
|
|
25
|
+
*
|
|
26
|
+
* The kill switch suppresses `user` only. Turning off customer email must not
|
|
27
|
+
* also blind the people running the shop: the daily digest is how a support
|
|
28
|
+
* message or a stuck payment proof gets noticed at all, and silencing it is
|
|
29
|
+
* how an outage becomes a week-long outage.
|
|
30
|
+
*/
|
|
31
|
+
export type MessageAudience = "user" | "staff";
|
|
32
|
+
/**
|
|
33
|
+
* What a caller must state before a message can leave the building.
|
|
34
|
+
*
|
|
35
|
+
* Every field is required except `transactional`, and that asymmetry is the
|
|
36
|
+
* point — a sender that cannot say who this is for and why has not thought
|
|
37
|
+
* about it, and an optional field defaults to whatever is least safe.
|
|
38
|
+
*/
|
|
39
|
+
export interface SendGuardContext {
|
|
40
|
+
channel: MessageChannel;
|
|
41
|
+
/**
|
|
42
|
+
* Stable identifier for WHAT is being sent — a notification type
|
|
43
|
+
* (`order_shipped`), or a named non-notification flow (`daily_digest`,
|
|
44
|
+
* `checkout_otp`, `payout_summary`).
|
|
45
|
+
*
|
|
46
|
+
* Recorded on every allow and deny, so "what ate the quota today" is a
|
|
47
|
+
* question with an answer.
|
|
48
|
+
*/
|
|
49
|
+
feature: string;
|
|
50
|
+
audience: MessageAudience;
|
|
51
|
+
/**
|
|
52
|
+
* The message is part of a flow the user is actively in, and suppressing it
|
|
53
|
+
* breaks that flow rather than merely quietening it.
|
|
54
|
+
*
|
|
55
|
+
* Exempt from the KILL SWITCH ONLY — the daily ceiling still applies, since
|
|
56
|
+
* the ceiling exists to stop the provider rejecting everything, and a
|
|
57
|
+
* transactional mail that would be rejected anyway is not worth pretending
|
|
58
|
+
* about.
|
|
59
|
+
*
|
|
60
|
+
* Only two things qualify today and the bar should stay this high:
|
|
61
|
+
* · the high-value checkout OTP — the buyer cannot complete an order above
|
|
62
|
+
* the threshold without the code;
|
|
63
|
+
* · the order confirmation — the buyer's receipt for money already taken.
|
|
64
|
+
*/
|
|
65
|
+
transactional?: boolean;
|
|
66
|
+
}
|
|
67
|
+
export type SendDecision = {
|
|
68
|
+
allow: true;
|
|
69
|
+
} | {
|
|
70
|
+
allow: false;
|
|
71
|
+
/** Machine-readable, for logs and the suppression record. */
|
|
72
|
+
reason: "type_ineligible" | "env_disabled" | "admin_disabled" | "daily_ceiling";
|
|
73
|
+
/** One line, for the log. Never shown to an end user. */
|
|
74
|
+
detail: string;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Defaults, mirrored into `DEFAULT_SITE_SETTINGS_DATA.messaging`.
|
|
78
|
+
*
|
|
79
|
+
* 🛑 `emailEnabled` and `whatsappEnabled` default to FALSE. An install that
|
|
80
|
+
* has not made a decision should be quiet, not loud — the opposite default is
|
|
81
|
+
* how a fresh deploy mails real people from a half-configured site.
|
|
82
|
+
*/
|
|
83
|
+
export declare const MESSAGE_BUDGET_DEFAULTS: {
|
|
84
|
+
readonly emailEnabled: false;
|
|
85
|
+
readonly whatsappEnabled: false;
|
|
86
|
+
readonly dailyCeiling: {
|
|
87
|
+
/**
|
|
88
|
+
* Resend's free tier is 100/day. 80 is not timidity — the counter is
|
|
89
|
+
* reserve-then-send and a burst can race slightly past the check, and
|
|
90
|
+
* Firebase-side auth mail (password resets, verification) draws on a
|
|
91
|
+
* different quota but the same inbox reputation. Twenty messages of
|
|
92
|
+
* headroom is what stops "at the limit" from becoming "over it".
|
|
93
|
+
*/
|
|
94
|
+
readonly email: 80;
|
|
95
|
+
/** Meta's pricing is per-conversation, not per-message; this is a sanity bound, not a tier limit. */
|
|
96
|
+
readonly whatsapp: 200;
|
|
97
|
+
/** No sender exists yet. Present so one cannot ship uncapped. */
|
|
98
|
+
readonly sms: 50;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Hard kill, checked before any Firestore read so it works even when Firestore
|
|
103
|
+
* is the thing that is unhappy.
|
|
104
|
+
*
|
|
105
|
+
* Any of `1` / `true` / `yes` counts, case-insensitively — an operator setting
|
|
106
|
+
* this at 3am should not have to remember which spelling the parser wanted.
|
|
107
|
+
*/
|
|
108
|
+
export declare function isEnvKillSwitchOn(): boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vocabulary for "may this message go out, and does the budget allow
|
|
3
|
+
* it" — the types both the server guard and the settings schema speak.
|
|
4
|
+
*
|
|
5
|
+
* Lives in `_internal/shared/` because the channel/audience unions are read on
|
|
6
|
+
* both sides: the guard enforces them server-side, the admin settings view
|
|
7
|
+
* renders them.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Defaults, mirrored into `DEFAULT_SITE_SETTINGS_DATA.messaging`.
|
|
11
|
+
*
|
|
12
|
+
* 🛑 `emailEnabled` and `whatsappEnabled` default to FALSE. An install that
|
|
13
|
+
* has not made a decision should be quiet, not loud — the opposite default is
|
|
14
|
+
* how a fresh deploy mails real people from a half-configured site.
|
|
15
|
+
*/
|
|
16
|
+
export const MESSAGE_BUDGET_DEFAULTS = {
|
|
17
|
+
emailEnabled: false,
|
|
18
|
+
whatsappEnabled: false,
|
|
19
|
+
dailyCeiling: {
|
|
20
|
+
/**
|
|
21
|
+
* Resend's free tier is 100/day. 80 is not timidity — the counter is
|
|
22
|
+
* reserve-then-send and a burst can race slightly past the check, and
|
|
23
|
+
* Firebase-side auth mail (password resets, verification) draws on a
|
|
24
|
+
* different quota but the same inbox reputation. Twenty messages of
|
|
25
|
+
* headroom is what stops "at the limit" from becoming "over it".
|
|
26
|
+
*/
|
|
27
|
+
email: 80,
|
|
28
|
+
/** Meta's pricing is per-conversation, not per-message; this is a sanity bound, not a tier limit. */
|
|
29
|
+
whatsapp: 200,
|
|
30
|
+
/** No sender exists yet. Present so one cannot ship uncapped. */
|
|
31
|
+
sms: 50,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Hard kill, checked before any Firestore read so it works even when Firestore
|
|
36
|
+
* is the thing that is unhappy.
|
|
37
|
+
*
|
|
38
|
+
* Any of `1` / `true` / `yes` counts, case-insensitively — an operator setting
|
|
39
|
+
* this at 3am should not have to remember which spelling the parser wanted.
|
|
40
|
+
*/
|
|
41
|
+
export function isEnvKillSwitchOn() {
|
|
42
|
+
const raw = process.env.EMAIL_DISABLED?.trim().toLowerCase() ?? "";
|
|
43
|
+
return raw === "1" || raw === "true" || raw === "yes";
|
|
44
|
+
}
|
|
@@ -155,4 +155,13 @@ export const TYPE_AUDIENCE = {
|
|
|
155
155
|
account_action: "buyer",
|
|
156
156
|
system: "buyer",
|
|
157
157
|
promotion: "buyer",
|
|
158
|
+
/*
|
|
159
|
+
* Both are "buyer" because that is where the RECIPIENT reads them — the
|
|
160
|
+
* ticket's owner at `/user/support`, the reporter at the scam registry.
|
|
161
|
+
* Staff do not receive either of these as notifications any more: the
|
|
162
|
+
* per-employee fan-out was replaced by a single `adminNotifications` row
|
|
163
|
+
* plus a daily digest section, so there is no staff audience to express.
|
|
164
|
+
*/
|
|
165
|
+
support_ticket_update: "buyer",
|
|
166
|
+
scam_report_update: "buyer",
|
|
158
167
|
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* May this notification type be EMAILED at all?
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is code and not the admin allow-list
|
|
5
|
+
*
|
|
6
|
+
* `siteSettings.notificationChannels.email.types?: NotificationType[]` already
|
|
7
|
+
* exists and is checked inside `sendNotification`. Setting it in the seed would
|
|
8
|
+
* have been one line — and the wrong one, three times over:
|
|
9
|
+
*
|
|
10
|
+
* · it is a plain array, so a NEW notification type is silently
|
|
11
|
+
* email-enabled. A `Record<Union, boolean>` makes it a compile error
|
|
12
|
+
* instead (Root Cause #61 — a Record is compile-safe, an array is not);
|
|
13
|
+
* · its empty case means "all types allowed", so an admin clearing the field
|
|
14
|
+
* reopens every one of them;
|
|
15
|
+
* · it is data, so a reseed quietly restores the old behaviour.
|
|
16
|
+
*
|
|
17
|
+
* So the two layers compose in one direction only: **this map decides what is
|
|
18
|
+
* eligible, the admin list may narrow it further, and neither can widen past
|
|
19
|
+
* here.**
|
|
20
|
+
*
|
|
21
|
+
* ## What this is actually protecting
|
|
22
|
+
*
|
|
23
|
+
* Resend's free tier is 100 emails/day, and three call sites could exhaust it
|
|
24
|
+
* on their own:
|
|
25
|
+
*
|
|
26
|
+
* · `auctionSettlement` sends `bid_lost` to every losing bidder, up to 50 in
|
|
27
|
+
* PARALLEL per auction, and settles all expired auctions concurrently —
|
|
28
|
+
* two auctions is the whole day;
|
|
29
|
+
* · `onScamReportCreate` fanned out one send per employee, up to 100;
|
|
30
|
+
* · `bid_outbid` is one email per bid per bidder on a live auction.
|
|
31
|
+
*
|
|
32
|
+
* The gate runs FIRST in `sendNotification`'s channel conjunctions — before the
|
|
33
|
+
* settings read, before the budget counter — so those 50 sends now exit with
|
|
34
|
+
* zero Firestore operations. That ordering is load-bearing: it is what lets the
|
|
35
|
+
* daily counter stay a single unsharded document.
|
|
36
|
+
*
|
|
37
|
+
* ## Nothing is lost by dropping a type from here
|
|
38
|
+
*
|
|
39
|
+
* `sendNotification` writes the in-app notification BEFORE any channel
|
|
40
|
+
* decision, so an ineligible type still fills the bell. Only the emailed copy
|
|
41
|
+
* goes away.
|
|
42
|
+
*/
|
|
43
|
+
import type { NotificationType } from "../../../../features/admin/schemas/firestore";
|
|
44
|
+
/**
|
|
45
|
+
* `true` = may email (subject to the admin allow-list, the kill switch, the
|
|
46
|
+
* user's own preference and the daily ceiling). `false` = in-app bell only.
|
|
47
|
+
*
|
|
48
|
+
* Typed on the union so a new notification type cannot compile without an
|
|
49
|
+
* answer to "is this worth an email?". Do not reach for a default — the whole
|
|
50
|
+
* value of this map is that the question gets asked once, deliberately, per
|
|
51
|
+
* type.
|
|
52
|
+
*/
|
|
53
|
+
export declare const EMAIL_ELIGIBLE_TYPES: Record<NotificationType, boolean>;
|
|
54
|
+
/**
|
|
55
|
+
* Prefer this over indexing the map directly — the lookup is `boolean`, and a
|
|
56
|
+
* bare `EMAIL_ELIGIBLE_TYPES[type]` inside a long `&&` chain reads as a flag
|
|
57
|
+
* rather than as a decision.
|
|
58
|
+
*/
|
|
59
|
+
export declare function isEmailEligible(type: NotificationType): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* For the admin UI, which renders an ineligible type as a DISABLED row rather
|
|
62
|
+
* than hiding it. Hiding would leave an admin wondering why `bid_outbid` is
|
|
63
|
+
* absent from a list that claims to cover every type; a disabled row with a
|
|
64
|
+
* reason answers the question in place.
|
|
65
|
+
*/
|
|
66
|
+
export declare const EMAIL_INELIGIBLE_TYPE_LIST: readonly NotificationType[];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* May this notification type be EMAILED at all?
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is code and not the admin allow-list
|
|
5
|
+
*
|
|
6
|
+
* `siteSettings.notificationChannels.email.types?: NotificationType[]` already
|
|
7
|
+
* exists and is checked inside `sendNotification`. Setting it in the seed would
|
|
8
|
+
* have been one line — and the wrong one, three times over:
|
|
9
|
+
*
|
|
10
|
+
* · it is a plain array, so a NEW notification type is silently
|
|
11
|
+
* email-enabled. A `Record<Union, boolean>` makes it a compile error
|
|
12
|
+
* instead (Root Cause #61 — a Record is compile-safe, an array is not);
|
|
13
|
+
* · its empty case means "all types allowed", so an admin clearing the field
|
|
14
|
+
* reopens every one of them;
|
|
15
|
+
* · it is data, so a reseed quietly restores the old behaviour.
|
|
16
|
+
*
|
|
17
|
+
* So the two layers compose in one direction only: **this map decides what is
|
|
18
|
+
* eligible, the admin list may narrow it further, and neither can widen past
|
|
19
|
+
* here.**
|
|
20
|
+
*
|
|
21
|
+
* ## What this is actually protecting
|
|
22
|
+
*
|
|
23
|
+
* Resend's free tier is 100 emails/day, and three call sites could exhaust it
|
|
24
|
+
* on their own:
|
|
25
|
+
*
|
|
26
|
+
* · `auctionSettlement` sends `bid_lost` to every losing bidder, up to 50 in
|
|
27
|
+
* PARALLEL per auction, and settles all expired auctions concurrently —
|
|
28
|
+
* two auctions is the whole day;
|
|
29
|
+
* · `onScamReportCreate` fanned out one send per employee, up to 100;
|
|
30
|
+
* · `bid_outbid` is one email per bid per bidder on a live auction.
|
|
31
|
+
*
|
|
32
|
+
* The gate runs FIRST in `sendNotification`'s channel conjunctions — before the
|
|
33
|
+
* settings read, before the budget counter — so those 50 sends now exit with
|
|
34
|
+
* zero Firestore operations. That ordering is load-bearing: it is what lets the
|
|
35
|
+
* daily counter stay a single unsharded document.
|
|
36
|
+
*
|
|
37
|
+
* ## Nothing is lost by dropping a type from here
|
|
38
|
+
*
|
|
39
|
+
* `sendNotification` writes the in-app notification BEFORE any channel
|
|
40
|
+
* decision, so an ineligible type still fills the bell. Only the emailed copy
|
|
41
|
+
* goes away.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* `true` = may email (subject to the admin allow-list, the kill switch, the
|
|
45
|
+
* user's own preference and the daily ceiling). `false` = in-app bell only.
|
|
46
|
+
*
|
|
47
|
+
* Typed on the union so a new notification type cannot compile without an
|
|
48
|
+
* answer to "is this worth an email?". Do not reach for a default — the whole
|
|
49
|
+
* value of this map is that the question gets asked once, deliberately, per
|
|
50
|
+
* type.
|
|
51
|
+
*/
|
|
52
|
+
export const EMAIL_ELIGIBLE_TYPES = {
|
|
53
|
+
// ── Money. The buyer is owed something, owes something, or just paid. ──────
|
|
54
|
+
order_placed: true,
|
|
55
|
+
order_confirmed: true,
|
|
56
|
+
order_shipped: true,
|
|
57
|
+
order_delivered: true,
|
|
58
|
+
order_cancelled: true,
|
|
59
|
+
refund_initiated: true,
|
|
60
|
+
payment_review: true,
|
|
61
|
+
emi_installment_due_soon: true,
|
|
62
|
+
emi_installment_overdue: true,
|
|
63
|
+
// ── Negotiation. Each one is a move the other party must respond to, and ──
|
|
64
|
+
// ── every one of them expires if they do not. ──
|
|
65
|
+
offer_received: true,
|
|
66
|
+
offer_responded: true,
|
|
67
|
+
offer_expired: true,
|
|
68
|
+
offer_counter_accepted: true,
|
|
69
|
+
// Won, and on a clock: the winner forfeits the lot if they miss the payment
|
|
70
|
+
// deadline. This is the one auction type that survives, and it survives
|
|
71
|
+
// BECAUSE of the deadline — not because winning is nice to hear about.
|
|
72
|
+
bid_won: true,
|
|
73
|
+
// Someone must claim this, and the claim window closes.
|
|
74
|
+
prize_won: true,
|
|
75
|
+
// Access to the account itself — a ban or an unban. After the 2026-09 split
|
|
76
|
+
// this means ONLY that; support tickets and scam reports moved to their own
|
|
77
|
+
// types precisely so this one could keep emailing. A banned user has to be
|
|
78
|
+
// told outside the app, because the app is what they just lost.
|
|
79
|
+
account_action: true,
|
|
80
|
+
// Signup and genuine one-off announcements. Low volume by nature.
|
|
81
|
+
welcome: true,
|
|
82
|
+
system: true,
|
|
83
|
+
// ── Bell only. ────────────────────────────────────────────────────────────
|
|
84
|
+
// The three auction fan-outs. `bid_lost` is up to 50 parallel sends per
|
|
85
|
+
// settled auction; `bid_outbid` is one per bid per bidder; `bid_placed`
|
|
86
|
+
// pings the seller on every single bid. All three are visible on the auction
|
|
87
|
+
// page and in the bell, and none of them is a deadline.
|
|
88
|
+
bid_lost: false,
|
|
89
|
+
bid_outbid: false,
|
|
90
|
+
bid_placed: false,
|
|
91
|
+
auction_ended: false,
|
|
92
|
+
// Marketing. The likeliest way to get the sending domain flagged, and the
|
|
93
|
+
// first thing a recipient marks as spam — which damages deliverability for
|
|
94
|
+
// the transactional mail above.
|
|
95
|
+
promotion: false,
|
|
96
|
+
product_available: false,
|
|
97
|
+
// Nothing for the user to do; the record is on the site when they next look.
|
|
98
|
+
review_approved: false,
|
|
99
|
+
review_replied: false,
|
|
100
|
+
// Housekeeping nags. `catalogue_images_stale` loops one send per stale item,
|
|
101
|
+
// so it is a quiet fan-out of its own.
|
|
102
|
+
catalogue_images_stale: false,
|
|
103
|
+
prize_reveal_expired: false,
|
|
104
|
+
// The conversation lives in the UI. Routine ticket back-and-forth costs
|
|
105
|
+
// nothing; a staff replier who genuinely needs one to land in an inbox ticks
|
|
106
|
+
// "also email the user", which sends `eligibilityOverride: "staff_requested"`
|
|
107
|
+
// for that one reply. The user side has no such tick — a user replying to
|
|
108
|
+
// their own ticket never triggers mail.
|
|
109
|
+
support_ticket_update: false,
|
|
110
|
+
// The reporter reads the outcome on the site; the admin side rides the daily
|
|
111
|
+
// digest. This was the second 100-way fan-out — `onScamReportCreate` sent one
|
|
112
|
+
// notification per employee, up to 100, for every report filed.
|
|
113
|
+
scam_report_update: false,
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Prefer this over indexing the map directly — the lookup is `boolean`, and a
|
|
117
|
+
* bare `EMAIL_ELIGIBLE_TYPES[type]` inside a long `&&` chain reads as a flag
|
|
118
|
+
* rather than as a decision.
|
|
119
|
+
*/
|
|
120
|
+
export function isEmailEligible(type) {
|
|
121
|
+
return EMAIL_ELIGIBLE_TYPES[type] === true;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* For the admin UI, which renders an ineligible type as a DISABLED row rather
|
|
125
|
+
* than hiding it. Hiding would leave an admin wondering why `bid_outbid` is
|
|
126
|
+
* absent from a list that claims to cover every type; a disabled row with a
|
|
127
|
+
* reason answers the question in place.
|
|
128
|
+
*/
|
|
129
|
+
export const EMAIL_INELIGIBLE_TYPE_LIST = Object.freeze(Object.keys(EMAIL_ELIGIBLE_TYPES).filter((t) => EMAIL_ELIGIBLE_TYPES[t] === false));
|
|
@@ -23,6 +23,16 @@ export declare const AUTH_ENDPOINTS: {
|
|
|
23
23
|
readonly SESSION_ACTIVITY: "/api/auth/session/activity";
|
|
24
24
|
readonly SESSION_VALIDATE: "/api/auth/session/validate";
|
|
25
25
|
readonly EVENT_INIT: "/api/auth/event/init";
|
|
26
|
+
/**
|
|
27
|
+
* Cooldown gate the client asks BEFORE calling Firebase's own
|
|
28
|
+
* `sendPasswordResetEmail` / `sendEmailVerification`.
|
|
29
|
+
*
|
|
30
|
+
* Those go browser → Firebase with no server hop, so this is the only
|
|
31
|
+
* throttle they have — nothing else on our side ever sees them. Advisory by
|
|
32
|
+
* design: a failure here resolves to "allowed", because a user locked out of
|
|
33
|
+
* their account must not also be locked out by our infrastructure.
|
|
34
|
+
*/
|
|
35
|
+
readonly MAIL_GATE: "/api/auth/mail-gate";
|
|
26
36
|
};
|
|
27
37
|
export declare const ACCOUNT_ENDPOINTS: {
|
|
28
38
|
/** @param userId — Firestore user document id */
|
|
@@ -480,6 +490,16 @@ export declare const API_ENDPOINTS: {
|
|
|
480
490
|
readonly SESSION_ACTIVITY: "/api/auth/session/activity";
|
|
481
491
|
readonly SESSION_VALIDATE: "/api/auth/session/validate";
|
|
482
492
|
readonly EVENT_INIT: "/api/auth/event/init";
|
|
493
|
+
/**
|
|
494
|
+
* Cooldown gate the client asks BEFORE calling Firebase's own
|
|
495
|
+
* `sendPasswordResetEmail` / `sendEmailVerification`.
|
|
496
|
+
*
|
|
497
|
+
* Those go browser → Firebase with no server hop, so this is the only
|
|
498
|
+
* throttle they have — nothing else on our side ever sees them. Advisory by
|
|
499
|
+
* design: a failure here resolves to "allowed", because a user locked out of
|
|
500
|
+
* their account must not also be locked out by our infrastructure.
|
|
501
|
+
*/
|
|
502
|
+
readonly MAIL_GATE: "/api/auth/mail-gate";
|
|
483
503
|
};
|
|
484
504
|
readonly ACCOUNT: {
|
|
485
505
|
/** @param userId — Firestore user document id */
|
|
@@ -939,6 +959,16 @@ export declare const API_ROUTES: {
|
|
|
939
959
|
readonly SESSION_ACTIVITY: "/api/auth/session/activity";
|
|
940
960
|
readonly SESSION_VALIDATE: "/api/auth/session/validate";
|
|
941
961
|
readonly EVENT_INIT: "/api/auth/event/init";
|
|
962
|
+
/**
|
|
963
|
+
* Cooldown gate the client asks BEFORE calling Firebase's own
|
|
964
|
+
* `sendPasswordResetEmail` / `sendEmailVerification`.
|
|
965
|
+
*
|
|
966
|
+
* Those go browser → Firebase with no server hop, so this is the only
|
|
967
|
+
* throttle they have — nothing else on our side ever sees them. Advisory by
|
|
968
|
+
* design: a failure here resolves to "allowed", because a user locked out of
|
|
969
|
+
* their account must not also be locked out by our infrastructure.
|
|
970
|
+
*/
|
|
971
|
+
readonly MAIL_GATE: "/api/auth/mail-gate";
|
|
942
972
|
};
|
|
943
973
|
readonly ACCOUNT: {
|
|
944
974
|
/** @param userId — Firestore user document id */
|
|
@@ -35,6 +35,16 @@ export const AUTH_ENDPOINTS = {
|
|
|
35
35
|
SESSION_ACTIVITY: "/api/auth/session/activity",
|
|
36
36
|
SESSION_VALIDATE: "/api/auth/session/validate",
|
|
37
37
|
EVENT_INIT: "/api/auth/event/init",
|
|
38
|
+
/**
|
|
39
|
+
* Cooldown gate the client asks BEFORE calling Firebase's own
|
|
40
|
+
* `sendPasswordResetEmail` / `sendEmailVerification`.
|
|
41
|
+
*
|
|
42
|
+
* Those go browser → Firebase with no server hop, so this is the only
|
|
43
|
+
* throttle they have — nothing else on our side ever sees them. Advisory by
|
|
44
|
+
* design: a failure here resolves to "allowed", because a user locked out of
|
|
45
|
+
* their account must not also be locked out by our infrastructure.
|
|
46
|
+
*/
|
|
47
|
+
MAIL_GATE: "/api/auth/mail-gate",
|
|
38
48
|
/*
|
|
39
49
|
* RESEND_VERIFICATION was here and is gone. The route was deleted in the
|
|
40
50
|
* Firebase-native auth migration (Root Cause #54: the Admin SDK cannot send
|
|
@@ -29,7 +29,7 @@ import type { NotificationType } from "../features/admin/schemas/firestore";
|
|
|
29
29
|
export { NOTIFICATION_TYPE_VALUES };
|
|
30
30
|
export type { NotificationType };
|
|
31
31
|
/** Alias kept for the two existing importers. */
|
|
32
|
-
export declare const NOTIFICATION_TYPES: 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"];
|
|
32
|
+
export declare const NOTIFICATION_TYPES: 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"];
|
|
33
33
|
/**
|
|
34
34
|
* `order_shipped` → `Order shipped`.
|
|
35
35
|
*
|
|
@@ -28,6 +28,26 @@ export interface SendNotificationInput extends NotificationCreateInput {
|
|
|
28
28
|
* the seller, `offer_responded` back to the buyer.
|
|
29
29
|
*/
|
|
30
30
|
audience?: NotificationAudience;
|
|
31
|
+
/**
|
|
32
|
+
* Bypass `EMAIL_ELIGIBLE_TYPES` for THIS send, because a human explicitly
|
|
33
|
+
* asked for it.
|
|
34
|
+
*
|
|
35
|
+
* The only caller is the support-ticket reply route, where a staff member
|
|
36
|
+
* ticked "also email the user" on a reply. `support_ticket_update` is
|
|
37
|
+
* ineligible by default precisely so routine back-and-forth costs nothing —
|
|
38
|
+
* this is the escape hatch for the reply that genuinely has to reach an
|
|
39
|
+
* inbox.
|
|
40
|
+
*
|
|
41
|
+
* 🛑 It bypasses ELIGIBILITY ONLY. The kill switch, the daily ceiling, the
|
|
42
|
+
* channel config and the user's own opt-out all still apply — a staff tick
|
|
43
|
+
* is a request, not an override of the site's posture or of the recipient's
|
|
44
|
+
* stated preference.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately a named literal rather than `boolean`: a `force: true` reads
|
|
47
|
+
* as "send harder" at the call site and invites reuse, while
|
|
48
|
+
* `"staff_requested"` states who is accountable for the send.
|
|
49
|
+
*/
|
|
50
|
+
eligibilityOverride?: "staff_requested";
|
|
31
51
|
}
|
|
32
52
|
export interface SendNotificationResult {
|
|
33
53
|
notification: NotificationDocument;
|
|
@@ -8,6 +8,7 @@ import { enqueueJob } from "../../jobs/actions/enqueue-job";
|
|
|
8
8
|
import { serverLogger } from "../../../monitoring";
|
|
9
9
|
import { decryptPii } from "../../../security/index";
|
|
10
10
|
import { resolveNotificationActionUrl, TYPE_AUDIENCE, } from "../../../_internal/shared/features/notifications/action-url";
|
|
11
|
+
import { isEmailEligible } from "../../../_internal/shared/features/notifications/email-eligibility";
|
|
11
12
|
import { renderNotificationEmail } from "../../email/notification-templates";
|
|
12
13
|
/**
|
|
13
14
|
* A notification's `actionUrl` is a site-relative path — correct for the
|
|
@@ -111,7 +112,7 @@ export async function getUnreadNotificationCount(userId) {
|
|
|
111
112
|
* 5. The required credentials are set in siteSettings.credentials
|
|
112
113
|
*/
|
|
113
114
|
export async function sendNotification(input) {
|
|
114
|
-
const { userEmail, userPhone, emailHtml, audience, ...notifInput } = input;
|
|
115
|
+
const { userEmail, userPhone, emailHtml, audience, eligibilityOverride, ...notifInput } = input;
|
|
115
116
|
/*
|
|
116
117
|
* Fill `actionUrl` when the caller did not.
|
|
117
118
|
*
|
|
@@ -131,6 +132,35 @@ export async function sendNotification(input) {
|
|
|
131
132
|
...notifInput,
|
|
132
133
|
...(resolvedActionUrl ? { actionUrl: resolvedActionUrl } : {}),
|
|
133
134
|
});
|
|
135
|
+
/*
|
|
136
|
+
* 🛑 The eligibility gate, and it is FIRST for a reason.
|
|
137
|
+
*
|
|
138
|
+
* `EMAIL_ELIGIBLE_TYPES` (see that file for the full argument) decides
|
|
139
|
+
* whether a type may leave the building at all. Checking it here — before
|
|
140
|
+
* the siteSettings read and before the user-prefs read — is what makes the
|
|
141
|
+
* expensive fan-outs free:
|
|
142
|
+
*
|
|
143
|
+
* `auctionSettlement` sends `bid_lost` to up to 50 losing bidders in
|
|
144
|
+
* parallel per auction. Each of those calls would otherwise pay TWO
|
|
145
|
+
* Firestore reads (settings + user doc) to arrive at "skipped". That is
|
|
146
|
+
* 100 reads per auction to send nothing, and every expired auction settles
|
|
147
|
+
* concurrently.
|
|
148
|
+
*
|
|
149
|
+
* Returning here costs zero reads, and it is also what lets the daily send
|
|
150
|
+
* counter stay a single unsharded document: the only remaining concurrent
|
|
151
|
+
* fan-out never reaches the counter.
|
|
152
|
+
*
|
|
153
|
+
* The bell is already filled by the write above, so nothing is lost — this
|
|
154
|
+
* suppresses the emailed/messaged COPY, not the notification.
|
|
155
|
+
*
|
|
156
|
+
* One map gates both channels deliberately. If a type is ever genuinely
|
|
157
|
+
* WhatsApp-worthy but not email-worthy, split the map then rather than
|
|
158
|
+
* loosening this early return.
|
|
159
|
+
*/
|
|
160
|
+
if (!isEmailEligible(notifInput.type) &&
|
|
161
|
+
eligibilityOverride !== "staff_requested") {
|
|
162
|
+
return { notification, email: "skipped", whatsapp: "skipped" };
|
|
163
|
+
}
|
|
134
164
|
// Load channel config + credentials (one Firestore read).
|
|
135
165
|
let settings;
|
|
136
166
|
try {
|
|
@@ -204,6 +234,18 @@ export async function sendNotification(input) {
|
|
|
204
234
|
// "Your catalogue photos are going stale" — housekeeping about the user's
|
|
205
235
|
// own account, which is what `system` covers.
|
|
206
236
|
catalogue_images_stale: "system",
|
|
237
|
+
/*
|
|
238
|
+
* Both land under `system` rather than getting new preference keys.
|
|
239
|
+
*
|
|
240
|
+
* Same reasoning as the EMI note above: an eighth and ninth key would have
|
|
241
|
+
* no stored value on any existing user, so it reads as on or off purely by
|
|
242
|
+
* accident of the default — a silent migration nobody asked for. And
|
|
243
|
+
* neither is something a user would plausibly want to silence
|
|
244
|
+
* independently: a ticket update is a reply to a conversation THEY started,
|
|
245
|
+
* and a scam-report outcome is the answer to a report THEY filed.
|
|
246
|
+
*/
|
|
247
|
+
support_ticket_update: "system",
|
|
248
|
+
scam_report_update: "system",
|
|
207
249
|
};
|
|
208
250
|
const typeKey = typeToPrefsKey[type];
|
|
209
251
|
// If the user has explicitly disabled this notification type, skip all external channels.
|