@mohasinac/appkit 4.35.0 → 4.35.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/checkout/actions.js +28 -22
- package/dist/_internal/server/features/orders/actions.js +58 -49
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +8 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
- package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
- package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
- package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
- package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
- package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
- package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
- package/dist/_internal/server/notifications/send-guard.js +142 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
- package/dist/_internal/shared/features/messaging/config.js +44 -0
- package/dist/_internal/shared/features/notifications/action-url.js +9 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
- package/dist/constants/api-endpoints.d.ts +30 -0
- package/dist/constants/api-endpoints.js +10 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/admin/actions/notification-actions.d.ts +20 -0
- package/dist/features/admin/actions/notification-actions.js +43 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
- package/dist/features/admin/schemas/firestore.d.ts +32 -2
- package/dist/features/admin/schemas/firestore.js +28 -0
- package/dist/features/auth/actions/profile-actions.d.ts +14 -1
- package/dist/features/auth/actions/profile-actions.js +16 -2
- package/dist/features/auth/hooks/useAuth.d.ts +5 -0
- package/dist/features/auth/hooks/useAuth.js +42 -0
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
- package/dist/features/contact/email.d.ts +30 -26
- package/dist/features/contact/email.js +65 -105
- package/dist/features/email/notification-templates.js +11 -0
- package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
- package/dist/features/messaging/repository/message-budget.repository.js +138 -0
- package/dist/features/messaging/schemas/firestore.d.ts +62 -0
- package/dist/features/messaging/schemas/firestore.js +36 -0
- package/dist/features/tester/seed-data/carts-tester-seed-data.d.ts +3 -0
- package/dist/features/tester/seed-data/carts-tester-seed-data.js +70 -0
- package/dist/features/tester/seed-data/index.d.ts +1 -0
- package/dist/features/tester/seed-data/index.js +1 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
- package/dist/seed/cart-seed-data.js +11 -0
- package/dist/seed/index.d.ts +1 -1
- package/dist/seed/index.js +1 -1
- package/dist/seed/manifest.js +2 -2
- package/dist/seed/notifications-seed-data.js +5 -1
- package/dist/seed/site-settings-seed-data.js +39 -10
- package/dist/server.d.ts +3 -5
- package/dist/server.js +27 -13
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +752 -752
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
125
|
+
if (!storeId) {
|
|
126
|
+
ctx.logger.info("Order has no storeId — nothing to announce to a seller", { orderId });
|
|
127
|
+
return;
|
|
108
128
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
type: "account_action",
|
|
43
|
-
priority: "normal",
|
|
52
|
+
await adminNotificationsRepository.create({
|
|
53
|
+
category: "fraud",
|
|
44
54
|
title: "New scam report submitted",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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.`,
|
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|