@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.
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +18 -3
- package/dist/_internal/client/features/layout/filterNavItems.d.ts +22 -1
- package/dist/_internal/client/features/layout/filterNavItems.js +26 -1
- package/dist/_internal/server/features/checkout/actions.js +28 -22
- package/dist/_internal/server/features/media/contextGuards.d.ts +1 -1
- package/dist/_internal/server/features/media/contextGuards.js +34 -1
- package/dist/_internal/server/features/orders/actions.js +58 -49
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +8 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
- package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
- package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
- package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
- package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
- package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
- package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
- package/dist/_internal/server/notifications/send-guard.js +142 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
- package/dist/_internal/shared/features/messaging/config.js +44 -0
- package/dist/_internal/shared/features/notifications/action-url.js +9 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
- package/dist/constants/api-endpoints.d.ts +30 -0
- package/dist/constants/api-endpoints.js +10 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/admin/actions/notification-actions.d.ts +20 -0
- package/dist/features/admin/actions/notification-actions.js +43 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
- package/dist/features/admin/schemas/firestore.d.ts +32 -2
- package/dist/features/admin/schemas/firestore.js +28 -0
- package/dist/features/auth/hooks/useAuth.d.ts +5 -0
- package/dist/features/auth/hooks/useAuth.js +42 -0
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
- package/dist/features/contact/email.d.ts +30 -26
- package/dist/features/contact/email.js +65 -105
- package/dist/features/email/notification-templates.js +11 -0
- package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
- package/dist/features/messaging/repository/message-budget.repository.js +138 -0
- package/dist/features/messaging/schemas/firestore.d.ts +62 -0
- package/dist/features/messaging/schemas/firestore.js +36 -0
- package/dist/features/products/components/CardStoreLine.js +22 -3
- package/dist/features/seller/components/SellerProductShell.js +7 -0
- package/dist/features/tester/seed-data/authored/seo__page-metadata.js +6 -6
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
- package/dist/seed/site-settings-seed-data.js +39 -10
- package/dist/server.d.ts +3 -5
- package/dist/server.js +27 -13
- package/package.json +1 -1
|
@@ -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));
|