@mohasinac/appkit 4.35.1 โ†’ 4.35.3

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 (58) hide show
  1. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +18 -3
  2. package/dist/_internal/client/features/layout/filterNavItems.d.ts +22 -1
  3. package/dist/_internal/client/features/layout/filterNavItems.js +26 -1
  4. package/dist/_internal/server/features/checkout/actions.js +28 -22
  5. package/dist/_internal/server/features/media/contextGuards.d.ts +1 -1
  6. package/dist/_internal/server/features/media/contextGuards.js +34 -1
  7. package/dist/_internal/server/features/orders/actions.js +58 -49
  8. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  9. package/dist/_internal/server/features/site-settings/adapters.js +8 -0
  10. package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
  11. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
  12. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
  13. package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
  14. package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
  15. package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
  16. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
  17. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
  18. package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
  19. package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
  20. package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
  21. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
  22. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
  23. package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
  24. package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
  25. package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
  26. package/dist/_internal/server/notifications/send-guard.js +142 -0
  27. package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
  28. package/dist/_internal/shared/features/messaging/config.js +44 -0
  29. package/dist/_internal/shared/features/notifications/action-url.js +9 -0
  30. package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
  31. package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
  32. package/dist/constants/api-endpoints.d.ts +30 -0
  33. package/dist/constants/api-endpoints.js +10 -0
  34. package/dist/constants/notification-types.d.ts +1 -1
  35. package/dist/features/admin/actions/notification-actions.d.ts +20 -0
  36. package/dist/features/admin/actions/notification-actions.js +43 -1
  37. package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
  38. package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
  39. package/dist/features/admin/schemas/firestore.d.ts +32 -2
  40. package/dist/features/admin/schemas/firestore.js +28 -0
  41. package/dist/features/auth/hooks/useAuth.d.ts +5 -0
  42. package/dist/features/auth/hooks/useAuth.js +42 -0
  43. package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
  44. package/dist/features/contact/email.d.ts +30 -26
  45. package/dist/features/contact/email.js +65 -105
  46. package/dist/features/email/notification-templates.js +11 -0
  47. package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
  48. package/dist/features/messaging/repository/message-budget.repository.js +138 -0
  49. package/dist/features/messaging/schemas/firestore.d.ts +62 -0
  50. package/dist/features/messaging/schemas/firestore.js +36 -0
  51. package/dist/features/products/components/CardStoreLine.js +22 -3
  52. package/dist/features/seller/components/SellerProductShell.js +7 -0
  53. package/dist/features/tester/seed-data/authored/seo__page-metadata.js +6 -6
  54. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
  55. package/dist/seed/site-settings-seed-data.js +39 -10
  56. package/dist/server.d.ts +3 -5
  57. package/dist/server.js +27 -13
  58. package/package.json +1 -1
@@ -21,13 +21,55 @@ import { normalizeError } from "../../../../errors/normalize";
21
21
  import { notificationRepository } from "../../../../features/admin/repository/notification.repository";
22
22
  import { sendWhatsAppTemplateMessage, sendWhatsAppBusinessMessage, } from "../../../../features/whatsapp-bot/helpers/whatsapp";
23
23
  import { isChannelHealthy, recordChannelOutcome } from "../../notifications/channel-health";
24
+ import { guardSend, logSuppressedSend } from "../../notifications/send-guard";
24
25
  const MAX_ATTEMPTS = 3;
25
26
  const RETRY_DELAY_MS = 1500;
26
27
  function delay(ms) {
27
28
  return new Promise((resolve) => setTimeout(resolve, ms));
28
29
  }
29
30
  export async function runWhatsAppNotify(payload, ctx) {
30
- const { toPhone, title, message, templateName, templateLanguage, notificationId, phoneNumberId, accessToken } = payload;
31
+ const { toPhone, title, message, type, templateName, templateLanguage, notificationId, phoneNumberId, accessToken } = payload;
32
+ /*
33
+ * The messaging guard, applied HERE rather than inside
34
+ * `sendWhatsAppBusinessMessage` itself.
35
+ *
36
+ * ๐Ÿ›‘ That placement is deliberate and must not be "improved" by pushing a
37
+ * required guard parameter down into the sender. `features/whatsapp-bot/helpers/whatsapp.ts`
38
+ * is re-exported from `appkit/src/index.ts` (via `isAdminNumber`), and
39
+ * `guardSend` reaches `firebase-admin` through the settings and budget
40
+ * repositories. A static import of it from that file would put
41
+ * `firebase-admin` in the main entry's import graph, which is precisely the
42
+ * Turbopack client-bundle trap in Root Cause #6/#24 โ€” it builds fine locally
43
+ * on webpack and fails every page in production.
44
+ *
45
+ * So the senders stay pure and each of the three real call sites guards
46
+ * itself; `scripts/audit-unguarded-send.mjs` enforces that a file calling a
47
+ * WhatsApp sender also calls `guardSend`.
48
+ *
49
+ * Note this job is only ever reached for an email/WhatsApp-ELIGIBLE type โ€”
50
+ * `sendNotification` returns before enqueuing otherwise โ€” so this is the
51
+ * ceiling and kill-switch check, not the type check.
52
+ */
53
+ const guard = {
54
+ channel: "whatsapp",
55
+ feature: type,
56
+ audience: "user",
57
+ };
58
+ const decision = await guardSend(guard);
59
+ if (!decision.allow) {
60
+ logSuppressedSend(guard, decision);
61
+ await notificationRepository
62
+ .update(notificationId, { whatsappStatus: "skipped" })
63
+ .catch((err) => {
64
+ void normalizeError(err);
65
+ });
66
+ return {
67
+ summary: { total: 1, succeeded: 0, skipped: 1, failed: 0 },
68
+ succeeded: [],
69
+ skipped: [notificationId],
70
+ failed: [],
71
+ };
72
+ }
31
73
  if (!(await isChannelHealthy("whatsapp"))) {
32
74
  ctx.logger.warn("whatsappNotify: WhatsApp channel circuit is open โ€” skipping send", { notificationId });
33
75
  await notificationRepository
@@ -6,6 +6,20 @@ export const onCatalogueSubmittedForApprovalHandler = async (event, ctx) => {
6
6
  return;
7
7
  const itemId = event.params.itemId ?? event.after?.id;
8
8
  const title = event.after?.title ?? "Untitled item";
9
+ /*
10
+ * No `as never` here any more, and the cast is worth a note because it was
11
+ * load-bearing in the wrong direction.
12
+ *
13
+ * `BaseRepository.create` takes `Omit<T, "id" | "createdAt" | "updatedAt">`
14
+ * and stamps its own timestamps. This call passed `createdAt: ctx.now`,
15
+ * which is an excess property โ€” so TS rejected it, someone reached for
16
+ * `as never`, and that silenced the type check on EVERY OTHER FIELD of the
17
+ * payload rather than just the offending one. The `createdAt` was being
18
+ * discarded at runtime regardless.
19
+ *
20
+ * Dropping the field is the whole fix. Five more call sites now follow this
21
+ * shape; none of them needs a cast either.
22
+ */
9
23
  await adminNotificationsRepository.create({
10
24
  category: "moderation",
11
25
  title: "Catalogue listing pending approval",
@@ -15,6 +29,5 @@ export const onCatalogueSubmittedForApprovalHandler = async (event, ctx) => {
15
29
  entityType: "catalogueItem",
16
30
  entityId: itemId,
17
31
  audienceUserIds: [],
18
- createdAt: ctx.now,
19
32
  });
20
33
  };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * May this message go out?
3
+ *
4
+ * One decision function, called from the only two places that know both WHO a
5
+ * message is for and WHY: `sendEmail()` and `sendNotification()`'s channel
6
+ * branches.
7
+ *
8
+ * ## Why not inside the Resend provider
9
+ *
10
+ * That would catch 100% of sends, which sounds like the stronger design and is
11
+ * not. The transport sees `to`, `subject` and `html` โ€” it has no notion of
12
+ * feature or audience, so it could not tell a customer marketing blast from the
13
+ * daily digest, and every kill-switch decision would degrade to all-or-nothing.
14
+ * Worse, a counter there plus a counter here would double-charge the same
15
+ * message.
16
+ *
17
+ * The guarantee that nothing bypasses this is STRUCTURAL instead: the guard
18
+ * argument is REQUIRED on `sendEmail` and on both WhatsApp senders, so an
19
+ * unguarded send does not typecheck, and `scripts/audit-unguarded-send.mjs`
20
+ * fails the build on a raw `createResendProvider().send()`. Make the wrong
21
+ * thing unrepresentable rather than adding a second layer of defence that has
22
+ * to be kept in sync with the first (CLAUDE.md Rule #22).
23
+ */
24
+ import { type SendDecision, type SendGuardContext } from "../../shared/features/messaging/config";
25
+ /**
26
+ * Evaluate the guard. Never throws โ€” a guard that can throw turns a config
27
+ * problem into a failed checkout.
28
+ *
29
+ * Order is deliberate and is cost-ordered: the two free checks run before the
30
+ * Firestore read, and the Firestore WRITE runs last, so a message that was
31
+ * never going out costs nothing to refuse.
32
+ */
33
+ export declare function guardSend(ctx: SendGuardContext): Promise<SendDecision>;
34
+ /**
35
+ * Record a suppression so "why did nobody get that" has an answer.
36
+ *
37
+ * Separate from `guardSend` so the decision function stays pure-ish and
38
+ * callable from a dry run. Deliberately `info`: a suppression is the system
39
+ * working, not failing โ€” the ceiling case above already logs at `error` on its
40
+ * own.
41
+ */
42
+ export declare function logSuppressedSend(ctx: SendGuardContext, decision: SendDecision): void;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * May this message go out?
3
+ *
4
+ * One decision function, called from the only two places that know both WHO a
5
+ * message is for and WHY: `sendEmail()` and `sendNotification()`'s channel
6
+ * branches.
7
+ *
8
+ * ## Why not inside the Resend provider
9
+ *
10
+ * That would catch 100% of sends, which sounds like the stronger design and is
11
+ * not. The transport sees `to`, `subject` and `html` โ€” it has no notion of
12
+ * feature or audience, so it could not tell a customer marketing blast from the
13
+ * daily digest, and every kill-switch decision would degrade to all-or-nothing.
14
+ * Worse, a counter there plus a counter here would double-charge the same
15
+ * message.
16
+ *
17
+ * The guarantee that nothing bypasses this is STRUCTURAL instead: the guard
18
+ * argument is REQUIRED on `sendEmail` and on both WhatsApp senders, so an
19
+ * unguarded send does not typecheck, and `scripts/audit-unguarded-send.mjs`
20
+ * fails the build on a raw `createResendProvider().send()`. Make the wrong
21
+ * thing unrepresentable rather than adding a second layer of defence that has
22
+ * to be kept in sync with the first (CLAUDE.md Rule #22).
23
+ */
24
+ import { normalizeError } from "../../../errors/normalize";
25
+ import { serverLogger } from "../../../monitoring";
26
+ import { siteSettingsRepository } from "../../../features/admin/repository/site-settings.repository";
27
+ import { messageBudgetRepository } from "../../../features/messaging/repository/message-budget.repository";
28
+ import { MESSAGE_BUDGET_DEFAULTS, isEnvKillSwitchOn, } from "../../shared/features/messaging/config";
29
+ /**
30
+ * Evaluate the guard. Never throws โ€” a guard that can throw turns a config
31
+ * problem into a failed checkout.
32
+ *
33
+ * Order is deliberate and is cost-ordered: the two free checks run before the
34
+ * Firestore read, and the Firestore WRITE runs last, so a message that was
35
+ * never going out costs nothing to refuse.
36
+ */
37
+ export async function guardSend(ctx) {
38
+ // 1. Hard kill. No I/O, so it still works when Firestore is the problem.
39
+ if (isEnvKillSwitchOn() && !ctx.transactional) {
40
+ return {
41
+ allow: false,
42
+ reason: "env_disabled",
43
+ detail: `EMAIL_DISABLED is set; suppressed ${ctx.channel}/${ctx.feature}`,
44
+ };
45
+ }
46
+ let settings = null;
47
+ try {
48
+ settings = await siteSettingsRepository.getSingleton();
49
+ }
50
+ catch (err) {
51
+ void normalizeError(err);
52
+ /*
53
+ * Fail OPEN on an unreadable settings document, and only here.
54
+ *
55
+ * The alternative โ€” refuse everything โ€” means one Firestore blip silences
56
+ * order confirmations and checkout OTPs site-wide. The ceiling below still
57
+ * applies with its default, so failing open cannot become unbounded; it
58
+ * just means the admin toggle is briefly ignored, which is the smaller of
59
+ * the two harms.
60
+ */
61
+ serverLogger.warn("guardSend: siteSettings unreadable โ€” falling back to defaults", {
62
+ channel: ctx.channel,
63
+ feature: ctx.feature,
64
+ });
65
+ }
66
+ const messaging = settings?.messaging;
67
+ // 2. Admin kill switch. USER-facing only โ€” staff mail is how an operator
68
+ // finds out anything is wrong, so it is never suppressed by this.
69
+ if (ctx.audience === "user" && !ctx.transactional) {
70
+ const channelEnabled = ctx.channel === "email"
71
+ ? (messaging?.emailEnabled ?? MESSAGE_BUDGET_DEFAULTS.emailEnabled)
72
+ : ctx.channel === "whatsapp"
73
+ ? (messaging?.whatsappEnabled ?? MESSAGE_BUDGET_DEFAULTS.whatsappEnabled)
74
+ : false; // sms has no sender; if one appears it starts disabled.
75
+ if (!channelEnabled) {
76
+ return {
77
+ allow: false,
78
+ reason: "admin_disabled",
79
+ detail: `${ctx.channel} is disabled for user-facing mail in Site Settings`,
80
+ };
81
+ }
82
+ }
83
+ // 3. The daily ceiling. Applies to EVERYTHING, including staff and
84
+ // transactional mail โ€” the ceiling exists to keep the provider from
85
+ // rejecting, and a provider that is rejecting rejects those too.
86
+ const ceiling = messaging?.dailyCeiling?.[ctx.channel] ??
87
+ MESSAGE_BUDGET_DEFAULTS.dailyCeiling[ctx.channel];
88
+ try {
89
+ const { allowed, count } = await messageBudgetRepository.reserveSend(ctx.channel, ceiling);
90
+ if (!allowed) {
91
+ // `error`, not `warn`: hitting the ceiling means real messages are being
92
+ // dropped, and it should be visible in the admin error list rather than
93
+ // buried among ordinary warnings.
94
+ serverLogger.error("guardSend: daily ceiling reached โ€” message suppressed", {
95
+ channel: ctx.channel,
96
+ feature: ctx.feature,
97
+ audience: ctx.audience,
98
+ transactional: ctx.transactional === true,
99
+ count,
100
+ ceiling,
101
+ });
102
+ return {
103
+ allow: false,
104
+ reason: "daily_ceiling",
105
+ detail: `${ctx.channel} daily ceiling of ${ceiling} reached`,
106
+ };
107
+ }
108
+ }
109
+ catch (err) {
110
+ void normalizeError(err);
111
+ /*
112
+ * Fail OPEN on a counter that will not write, for the same reason as the
113
+ * settings read above โ€” but note the asymmetry: this branch cannot be
114
+ * reached without Firestore being broadly unavailable, in which case very
115
+ * little is being sent anyway.
116
+ */
117
+ serverLogger.warn("guardSend: budget reservation failed โ€” allowing send", {
118
+ channel: ctx.channel,
119
+ feature: ctx.feature,
120
+ });
121
+ }
122
+ return { allow: true };
123
+ }
124
+ /**
125
+ * Record a suppression so "why did nobody get that" has an answer.
126
+ *
127
+ * Separate from `guardSend` so the decision function stays pure-ish and
128
+ * callable from a dry run. Deliberately `info`: a suppression is the system
129
+ * working, not failing โ€” the ceiling case above already logs at `error` on its
130
+ * own.
131
+ */
132
+ export function logSuppressedSend(ctx, decision) {
133
+ if (decision.allow)
134
+ return;
135
+ serverLogger.info("send suppressed", {
136
+ channel: ctx.channel,
137
+ feature: ctx.feature,
138
+ audience: ctx.audience,
139
+ reason: decision.reason,
140
+ detail: decision.detail,
141
+ });
142
+ }
@@ -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));