@mohasinac/appkit 4.35.0 → 4.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) 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/actions/profile-actions.d.ts +14 -1
  37. package/dist/features/auth/actions/profile-actions.js +16 -2
  38. package/dist/features/auth/hooks/useAuth.d.ts +5 -0
  39. package/dist/features/auth/hooks/useAuth.js +42 -0
  40. package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
  41. package/dist/features/contact/email.d.ts +30 -26
  42. package/dist/features/contact/email.js +65 -105
  43. package/dist/features/email/notification-templates.js +11 -0
  44. package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
  45. package/dist/features/messaging/repository/message-budget.repository.js +138 -0
  46. package/dist/features/messaging/schemas/firestore.d.ts +62 -0
  47. package/dist/features/messaging/schemas/firestore.js +36 -0
  48. package/dist/features/tester/seed-data/carts-tester-seed-data.d.ts +3 -0
  49. package/dist/features/tester/seed-data/carts-tester-seed-data.js +70 -0
  50. package/dist/features/tester/seed-data/index.d.ts +1 -0
  51. package/dist/features/tester/seed-data/index.js +1 -0
  52. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
  53. package/dist/seed/cart-seed-data.js +11 -0
  54. package/dist/seed/index.d.ts +1 -1
  55. package/dist/seed/index.js +1 -1
  56. package/dist/seed/manifest.js +2 -2
  57. package/dist/seed/notifications-seed-data.js +5 -1
  58. package/dist/seed/site-settings-seed-data.js +39 -10
  59. package/dist/server.d.ts +3 -5
  60. package/dist/server.js +27 -13
  61. package/package.json +1 -1
  62. package/scripts/seed-cli.mjs +752 -752
@@ -1,8 +1,9 @@
1
1
  import { normalizeError } from "../../../../errors/normalize";
2
- import { storeRepository, userRepository } from "../../../../repositories";
2
+ import { adminNotificationsRepository, storeRepository, userRepository } from "../../../../repositories";
3
3
  import { decryptPii } from "../../../../security/index";
4
4
  import { sendWhatsAppBusinessMessage, buildPurchaseAnnouncementMessage, } from "../../../../features/whatsapp-bot/server";
5
5
  import { resolveKeys } from "../../../../core/integration-keys";
6
+ import { guardSend, logSuppressedSend } from "../../notifications/send-guard";
6
7
  async function sendAnnouncement(ctx, toPhone, message, phoneNumberId, accessToken, label, orderId) {
7
8
  try {
8
9
  const sent = await sendWhatsAppBusinessMessage({
@@ -68,21 +69,10 @@ async function resolveWhatsAppCredentials(ctx, orderId) {
68
69
  return {
69
70
  phoneNumberId: usable(db.whatsappPhoneNumberId) || usable(ctx.env("WHATSAPP_PHONE_NUMBER_ID")),
70
71
  accessToken: usable(db.whatsappCloudApiToken) || usable(ctx.env("WHATSAPP_CLOUD_API_TOKEN")),
71
- adminNumbersRaw: usable(db.whatsappAdminNotifyNumbers) || usable(ctx.env("WHATSAPP_ADMIN_NOTIFY_NUMBERS")),
72
72
  };
73
73
  }
74
74
  export async function handleOrderCreate(input, ctx) {
75
75
  const { orderId, order } = input;
76
- // Credentials resolve DB-first (Admin → Site Settings → WhatsApp) with an env
77
- // fallback, matching resolveKeys() and every other WhatsApp send path. This
78
- // read ctx.env() ONLY until 2026-08-22, so credentials saved in Site Settings
79
- // never reached the order-placed announcement — it silently no-op'd unless the
80
- // Functions runtime also carried the env vars.
81
- const { phoneNumberId, accessToken, adminNumbersRaw } = await resolveWhatsAppCredentials(ctx, orderId);
82
- if (!phoneNumberId || !accessToken) {
83
- ctx.logger.info("WhatsApp Cloud API not configured — skipping announcement", { orderId });
84
- return;
85
- }
86
76
  const items = order.items ?? [];
87
77
  const firstItem = items[0];
88
78
  const firstItemName = firstItem?.productTitle ?? "an item";
@@ -95,20 +85,67 @@ export async function handleOrderCreate(input, ctx) {
95
85
  totalAmount: order.totalPrice ?? 0,
96
86
  orderId,
97
87
  });
98
- const adminNumbers = adminNumbersRaw
99
- .split(",")
100
- .map((n) => n.trim().replace(/\D/g, ""))
101
- .filter(Boolean);
102
- for (const num of adminNumbers) {
103
- await sendAnnouncement(ctx, num, message, phoneNumberId, accessToken, "admin", orderId);
88
+ /*
89
+ * Staff side: one durable row, not a WhatsApp message per admin number.
90
+ *
91
+ * 🛑 This used to loop `whatsappAdminNotifyNumbers` and send the purchase
92
+ * announcement to every one of them, ON EVERY ORDER — the highest-volume
93
+ * staff blast in the codebase. It is now an `adminNotifications` row that
94
+ * the admin inbox reads and the daily digest counts.
95
+ *
96
+ * Written BEFORE the credential check on purpose. The old code returned
97
+ * early when WhatsApp was unconfigured, which meant that with no Meta
98
+ * credentials — the state this project is actually in — an order produced
99
+ * no staff signal at all, and no record that it hadn't.
100
+ */
101
+ try {
102
+ await adminNotificationsRepository.create({
103
+ category: "growth",
104
+ title: "New order placed",
105
+ body: message,
106
+ severity: "info",
107
+ isRead: false,
108
+ entityType: "order",
109
+ entityId: orderId,
110
+ audienceUserIds: [],
111
+ });
104
112
  }
113
+ catch (err) {
114
+ void normalizeError(err);
115
+ ctx.logger.error("Failed to write admin order notification (non-fatal)", err, { orderId });
116
+ }
117
+ /*
118
+ * Seller side keeps WhatsApp: it is ONE message to ONE person about their
119
+ * own sale, which is the case the channel is good at. Credentials resolve
120
+ * DB-first (Admin → Site Settings → WhatsApp) with an env fallback — this
121
+ * read `ctx.env()` only until 2026-08-22, so credentials saved in Site
122
+ * Settings never reached the announcement and it silently no-op'd.
123
+ */
105
124
  const storeId = order.storeId;
106
- if (storeId) {
107
- await notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId);
125
+ if (!storeId) {
126
+ ctx.logger.info("Order has no storeId — nothing to announce to a seller", { orderId });
127
+ return;
108
128
  }
109
- ctx.logger.info(`Order announcement complete`, {
110
- orderId,
111
- adminCount: adminNumbers.length,
112
- storeId: storeId ?? "unknown",
113
- });
129
+ const { phoneNumberId, accessToken } = await resolveWhatsAppCredentials(ctx, orderId);
130
+ if (!phoneNumberId || !accessToken) {
131
+ ctx.logger.info("WhatsApp Cloud API not configured — skipping seller announcement", { orderId });
132
+ return;
133
+ }
134
+ // Audience is "user": a seller receiving news of their own sale is a
135
+ // marketplace participant, not an operator of the site. Staff exemption is
136
+ // for the digest and the payout summary, and widening it to "anyone we think
137
+ // ought to see this" would make the kill switch mean nothing.
138
+ const guard = {
139
+ channel: "whatsapp",
140
+ feature: "order_placed_seller",
141
+ audience: "user",
142
+ };
143
+ const decision = await guardSend(guard);
144
+ if (!decision.allow) {
145
+ logSuppressedSend(guard, decision);
146
+ ctx.logger.info("Seller order announcement suppressed by messaging guard", { orderId, storeId });
147
+ return;
148
+ }
149
+ await notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId);
150
+ ctx.logger.info(`Order announcement complete`, { orderId, storeId });
114
151
  }
@@ -1,5 +1,5 @@
1
1
  import { normalizeError } from "../../../../errors/normalize";
2
- import { userRepository } from "../../../../repositories";
2
+ import { adminNotificationsRepository, userRepository } from "../../../../repositories";
3
3
  import { sendNotification } from "../../../../features/admin/actions/notification-actions";
4
4
  import { SCAM_TYPE_LABELS } from "../../../../features/scams/constants/scam-types";
5
5
  export async function handleScamReportCreate(input, ctx) {
@@ -12,7 +12,7 @@ export async function handleScamReportCreate(input, ctx) {
12
12
  const reporter = await userRepository.findById(reportedBy);
13
13
  await sendNotification({
14
14
  userId: reportedBy,
15
- type: "account_action",
15
+ type: "scam_report_update",
16
16
  priority: "normal",
17
17
  title: "Scam report submitted",
18
18
  message: `Your report for "${name}" has been received. Our team will review it within 48 hours.`,
@@ -27,31 +27,42 @@ export async function handleScamReportCreate(input, ctx) {
27
27
  ctx.logger.error("Failed to notify reporter (non-fatal)", err, { scammerId, reportedBy });
28
28
  }
29
29
  }
30
- // 2. Notify all employees with admin:scammers:read permission
30
+ /*
31
+ * 2. Tell STAFF — once, into the admin inbox.
32
+ *
33
+ * 🛑 This used to be `Promise.all` over `userRepository.list({ pageSize: 100 })`,
34
+ * i.e. **one `sendNotification` per employee, up to 100, per report filed**.
35
+ * Each of those was a potential email against a 100/day Resend allowance, so
36
+ * a single scam report could consume the entire day — and it was a hundred
37
+ * concurrent Firestore writes besides.
38
+ *
39
+ * It was also the wrong shape regardless of cost: a scam report is one
40
+ * event needing one person to action it, not N personal messages. The
41
+ * `adminNotifications` collection exists for exactly this and is already
42
+ * read by `GET /api/admin/admin-notifications` and surfaced in the admin
43
+ * inbox — `audienceUserIds: []` means "all admins" rather than a fan-out.
44
+ *
45
+ * The daily digest carries the count as well, so nothing depends on someone
46
+ * happening to look at the inbox.
47
+ */
31
48
  try {
32
- const result = await userRepository.list({
33
- filters: "role==employee",
34
- page: 1,
35
- pageSize: 100,
36
- });
37
49
  const scamTypeLabel = scamType ? (SCAM_TYPE_LABELS[scamType] ?? scamType) : "Unknown";
38
50
  const amountStr = amountLost ? ` ₹${amountLost.toLocaleString("en-IN")}` : "";
39
51
  const platformStr = scamPlatform ? ` via ${scamPlatform}` : "";
40
- await Promise.all(result.items.filter((e) => !!e.id).map((employee) => sendNotification({
41
- userId: employee.id,
42
- type: "account_action",
43
- priority: "normal",
52
+ await adminNotificationsRepository.create({
53
+ category: "fraud",
44
54
  title: "New scam report submitted",
45
- message: `A report was submitted for "${name}" — ${scamTypeLabel}${platformStr}.${amountStr}`,
46
- relatedId: scammerId,
47
- relatedType: "scammer",
48
- userEmail: employee.email ?? undefined,
49
- userPhone: employee.phoneNumber ?? undefined,
50
- }).catch((err) => ctx.logger.error("Failed to notify employee (non-fatal)", err, { scammerId, employeeId: employee.id ?? null }))));
55
+ body: `A report was submitted for "${name}" — ${scamTypeLabel}${platformStr}.${amountStr}`,
56
+ severity: "warning",
57
+ isRead: false,
58
+ entityType: "scammer",
59
+ entityId: scammerId,
60
+ audienceUserIds: [],
61
+ });
51
62
  }
52
63
  catch (err) {
53
64
  void normalizeError(err);
54
- ctx.logger.error("Failed to query employees for scam notification (non-fatal)", err, { scammerId });
65
+ ctx.logger.error("Failed to write admin scam-report notification (non-fatal)", err, { scammerId });
55
66
  }
56
67
  ctx.logger.info("onScamReportCreate complete", { scammerId, reportedBy: reportedBy ?? null });
57
68
  }
@@ -15,7 +15,7 @@ export async function handleScamReportRejected(input, ctx) {
15
15
  const reporter = await userRepository.findById(reportedBy);
16
16
  await sendNotification({
17
17
  userId: reportedBy,
18
- type: "account_action",
18
+ type: "scam_report_update",
19
19
  priority: "normal",
20
20
  title: "Scam report not verified",
21
21
  message: `Your report for "${name}" could not be verified with the evidence provided. You may submit a new report with additional evidence.`,
@@ -15,7 +15,7 @@ export async function handleScamReportVerified(input, ctx) {
15
15
  const reporter = await userRepository.findById(reportedBy);
16
16
  await sendNotification({
17
17
  userId: reportedBy,
18
- type: "account_action",
18
+ type: "scam_report_update",
19
19
  priority: "normal",
20
20
  title: "Your scam report was verified",
21
21
  message: `The report for "${name}" has been verified and published to the Scam Registry. Thank you for helping protect the community.`,
@@ -10,7 +10,7 @@ export async function handleSupportTicketCreate(input, ctx) {
10
10
  try {
11
11
  await sendNotification({
12
12
  userId,
13
- type: "account_action",
13
+ type: "support_ticket_update",
14
14
  priority: "normal",
15
15
  title: "Support ticket received",
16
16
  message: `We received your support request: "${ticket.subject ?? "your ticket"}". We'll get back to you soon.`,
@@ -35,7 +35,7 @@ export async function handleSupportTicketUpdate(input, ctx) {
35
35
  try {
36
36
  await sendNotification({
37
37
  userId,
38
- type: "account_action",
38
+ type: "support_ticket_update",
39
39
  priority: "normal",
40
40
  title: msg.title,
41
41
  message: msg.body(subject),
@@ -76,15 +76,27 @@ export async function runPaymentReviewAutoApprove(ctx) {
76
76
  ctx.logger.error("Failed to auto-approve payment proof", err, { orderId: entry.id });
77
77
  }
78
78
  }
79
- await Promise.allSettled(unreviewed.map((entry) => sendNotification({
80
- userId: entry.data.userId,
81
- type: "payment_review",
82
- priority: "normal",
83
- title: ORDER_MESSAGES.AUTO_APPROVED_TITLE,
84
- message: ORDER_MESSAGES.AUTO_APPROVED_MESSAGE(entry.data.productTitle),
85
- relatedId: entry.id,
86
- relatedType: "order",
87
- })));
79
+ // Sequential: `payment_review` is email-eligible, so each send reserves a
80
+ // unit from the single-document daily budget counter. See pendingOrderTimeout.
81
+ for (const entry of unreviewed) {
82
+ try {
83
+ await sendNotification({
84
+ userId: entry.data.userId,
85
+ type: "payment_review",
86
+ priority: "normal",
87
+ title: ORDER_MESSAGES.AUTO_APPROVED_TITLE,
88
+ message: ORDER_MESSAGES.AUTO_APPROVED_MESSAGE(entry.data.productTitle),
89
+ relatedId: entry.id,
90
+ relatedType: "order",
91
+ });
92
+ }
93
+ catch (err) {
94
+ void normalizeError(err);
95
+ ctx.logger.error("Failed to notify buyer of auto-approved payment (non-fatal)", err, {
96
+ orderId: entry.id,
97
+ });
98
+ }
99
+ }
88
100
  ctx.logger.info("Payment review auto-approve sweep complete", {
89
101
  scanned: unreviewed.length,
90
102
  approved,
@@ -43,16 +43,29 @@ export async function runPaymentWindowTimeout(ctx) {
43
43
  });
44
44
  }
45
45
  }
46
- await Promise.allSettled(expired.map((entry) => sendNotification({
47
- userId: entry.data.userId,
48
- type: "order_cancelled",
49
- priority: "normal",
50
- title: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_TITLE,
51
- message: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_MESSAGE(entry.data.productTitle),
52
- relatedId: entry.id,
53
- relatedType: "order",
54
- orderWhatsappAddonPaid: entry.data.whatsappNotifyAddon === true,
55
- })));
46
+ // Sequential: `order_cancelled` is email-eligible, so each send reserves a
47
+ // unit from the single-document daily budget counter and a concurrent sweep
48
+ // would contend on it. See pendingOrderTimeout for the full reasoning.
49
+ for (const entry of expired) {
50
+ try {
51
+ await sendNotification({
52
+ userId: entry.data.userId,
53
+ type: "order_cancelled",
54
+ priority: "normal",
55
+ title: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_TITLE,
56
+ message: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_MESSAGE(entry.data.productTitle),
57
+ relatedId: entry.id,
58
+ relatedType: "order",
59
+ orderWhatsappAddonPaid: entry.data.whatsappNotifyAddon === true,
60
+ });
61
+ }
62
+ catch (err) {
63
+ void normalizeError(err);
64
+ ctx.logger.error("Failed to notify buyer of expired payment window (non-fatal)", err, {
65
+ orderId: entry.id,
66
+ });
67
+ }
68
+ }
56
69
  ctx.logger.info("Payment window timeout sweep complete", {
57
70
  scanned: expired.length,
58
71
  restored,
@@ -41,15 +41,41 @@ export async function runPendingOrderTimeout(ctx) {
41
41
  });
42
42
  }
43
43
  }
44
- await Promise.allSettled(timedOut.map((entry) => sendNotification({
45
- userId: entry.data.userId,
46
- type: "order_cancelled",
47
- priority: "normal",
48
- title: ORDER_MESSAGES.CANCELLED_TITLE,
49
- message: ORDER_MESSAGES.CANCELLED_TIMEOUT_MESSAGE(entry.data.productTitle, timeoutHours),
50
- relatedId: entry.id,
51
- relatedType: "order",
52
- orderWhatsappAddonPaid: entry.data.whatsappNotifyAddon === true,
53
- })));
44
+ /*
45
+ * Sequential, not `Promise.allSettled`.
46
+ *
47
+ * 🛑 `order_cancelled` is email-ELIGIBLE, so each of these reserves a unit
48
+ * from the daily budget — a single Firestore document. Firestore sustains
49
+ * roughly one write per second on one document, so a concurrent sweep would
50
+ * contend on it, retry, and slow the job down more than the serial version
51
+ * it replaced. Awaiting in a loop is the cheap fix; the alternative is
52
+ * sharding the counter to defend a fan-out nobody needs.
53
+ *
54
+ * Safe on latency: this runs in a Firebase Function with a 300s budget, and
55
+ * the set is bounded by however many orders timed out in one window.
56
+ *
57
+ * Failures stay non-fatal per order — a notification that will not send must
58
+ * not stop the remaining cancellations being announced.
59
+ */
60
+ for (const entry of timedOut) {
61
+ try {
62
+ await sendNotification({
63
+ userId: entry.data.userId,
64
+ type: "order_cancelled",
65
+ priority: "normal",
66
+ title: ORDER_MESSAGES.CANCELLED_TITLE,
67
+ message: ORDER_MESSAGES.CANCELLED_TIMEOUT_MESSAGE(entry.data.productTitle, timeoutHours),
68
+ relatedId: entry.id,
69
+ relatedType: "order",
70
+ orderWhatsappAddonPaid: entry.data.whatsappNotifyAddon === true,
71
+ });
72
+ }
73
+ catch (err) {
74
+ void normalizeError(err);
75
+ ctx.logger.error("Failed to notify buyer of timed-out order (non-fatal)", err, {
76
+ orderId: entry.id,
77
+ });
78
+ }
79
+ }
54
80
  ctx.logger.info("Pending order timeout complete", { cancelled: timedOut.length, restored });
55
81
  }
@@ -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
+ }