@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.
Files changed (50) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +28 -22
  2. package/dist/_internal/server/features/orders/actions.js +58 -49
  3. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  4. package/dist/_internal/server/features/site-settings/adapters.js +8 -0
  5. package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
  6. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
  7. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
  8. package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
  9. package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
  10. package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
  11. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
  12. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
  13. package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
  14. package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
  15. package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
  16. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
  17. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
  18. package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
  19. package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
  20. package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
  21. package/dist/_internal/server/notifications/send-guard.js +142 -0
  22. package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
  23. package/dist/_internal/shared/features/messaging/config.js +44 -0
  24. package/dist/_internal/shared/features/notifications/action-url.js +9 -0
  25. package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
  26. package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
  27. package/dist/constants/api-endpoints.d.ts +30 -0
  28. package/dist/constants/api-endpoints.js +10 -0
  29. package/dist/constants/notification-types.d.ts +1 -1
  30. package/dist/features/admin/actions/notification-actions.d.ts +20 -0
  31. package/dist/features/admin/actions/notification-actions.js +43 -1
  32. package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
  33. package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
  34. package/dist/features/admin/schemas/firestore.d.ts +32 -2
  35. package/dist/features/admin/schemas/firestore.js +28 -0
  36. package/dist/features/auth/hooks/useAuth.d.ts +5 -0
  37. package/dist/features/auth/hooks/useAuth.js +42 -0
  38. package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
  39. package/dist/features/contact/email.d.ts +30 -26
  40. package/dist/features/contact/email.js +65 -105
  41. package/dist/features/email/notification-templates.js +11 -0
  42. package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
  43. package/dist/features/messaging/repository/message-budget.repository.js +138 -0
  44. package/dist/features/messaging/schemas/firestore.d.ts +62 -0
  45. package/dist/features/messaging/schemas/firestore.js +36 -0
  46. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
  47. package/dist/seed/site-settings-seed-data.js +39 -10
  48. package/dist/server.d.ts +3 -5
  49. package/dist/server.js +27 -13
  50. 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.