@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,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import "server-only";
|
|
3
3
|
// Lazy-load `renderToStaticMarkup` via `require()` to avoid Next.js's
|
|
4
4
|
// Server-Component static-trace error. Even with the `server-only`
|
|
@@ -12,12 +12,13 @@ function renderToStaticMarkup(el) {
|
|
|
12
12
|
return m.renderToStaticMarkup(el);
|
|
13
13
|
}
|
|
14
14
|
import { normalizeError } from "../../errors/normalize";
|
|
15
|
+
import { guardSend, logSuppressedSend } from "../../_internal/server/notifications/send-guard";
|
|
15
16
|
import { getProviders } from "../../contracts";
|
|
16
17
|
import { createResendProvider } from "../../providers/email-resend/provider";
|
|
17
18
|
import { siteSettingsRepository } from "../admin/repository/site-settings.repository";
|
|
18
19
|
import { serverLogger } from "../../monitoring";
|
|
19
|
-
import { currentYear, formatCurrency,
|
|
20
|
-
import { EmailBold, EmailButton, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader,
|
|
20
|
+
import { currentYear, formatCurrency, } from "../../utils";
|
|
21
|
+
import { EmailBold, EmailButton, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailRow, } from "../email/primitives";
|
|
21
22
|
function getSiteName() {
|
|
22
23
|
return process.env.NEXT_PUBLIC_SITE_NAME?.trim() || "App";
|
|
23
24
|
}
|
|
@@ -79,7 +80,22 @@ async function resolveEmailProvider() {
|
|
|
79
80
|
});
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Send one email, subject to the messaging guard.
|
|
85
|
+
*
|
|
86
|
+
* `suppressed` is a THIRD outcome, distinct from both success and failure, and
|
|
87
|
+
* callers must treat it as such: `{ data: null, error: null }` means the
|
|
88
|
+
* message was deliberately not sent, so a caller that only checks `error` will
|
|
89
|
+
* correctly not raise an alarm, and one that wants to know can read
|
|
90
|
+
* `suppressed`. Returning an `error` for a suppression would have turned every
|
|
91
|
+
* intentional quiet into a logged incident.
|
|
92
|
+
*/
|
|
93
|
+
export async function sendEmail(opts, guard) {
|
|
94
|
+
const decision = await guardSend(guard);
|
|
95
|
+
if (!decision.allow) {
|
|
96
|
+
logSuppressedSend(guard, decision);
|
|
97
|
+
return { data: null, error: null, suppressed: decision.reason };
|
|
98
|
+
}
|
|
83
99
|
try {
|
|
84
100
|
const provider = await resolveEmailProvider();
|
|
85
101
|
const data = await provider.send({
|
|
@@ -103,54 +119,12 @@ export async function sendEmail(opts) {
|
|
|
103
119
|
return { data: null, error: error instanceof Error ? error.message : String(error) };
|
|
104
120
|
}
|
|
105
121
|
}
|
|
106
|
-
async function sendConfiguredEmail(opts) {
|
|
107
|
-
return sendEmail(opts);
|
|
122
|
+
async function sendConfiguredEmail(opts, guard) {
|
|
123
|
+
return sendEmail(opts, guard);
|
|
108
124
|
}
|
|
109
125
|
const PARA_STYLE = { margin: "0 0 12px", fontSize: "14px", lineHeight: "1.6" };
|
|
110
126
|
const LABEL_STYLE = { color: "#71717a", fontSize: "13px" };
|
|
111
127
|
const VALUE_STYLE = { color: "#18181b", fontSize: "14px", fontWeight: 600 };
|
|
112
|
-
export async function sendVerificationEmailWithLink(email, verificationLink) {
|
|
113
|
-
const siteName = getSiteName();
|
|
114
|
-
const html = `<!DOCTYPE html>${renderToStaticMarkup(_jsx(EmailDoc, { title: `Verify your ${siteName} email`, children: _jsxs(EmailContainer, { children: [_jsx(EmailHeader, { brandName: siteName }), _jsxs(EmailRow, { children: [_jsx("p", { style: PARA_STYLE, children: "Click the link below to verify your email address. This link expires in 24 hours." }), _jsx(EmailButton, { href: verificationLink, children: "Verify email" })] }), _jsx(EmailFooter, { copyright: `© ${currentYear()} ${siteName}. All rights reserved.` })] }) }))}`;
|
|
115
|
-
try {
|
|
116
|
-
const { data, error } = await sendConfiguredEmail({
|
|
117
|
-
to: email,
|
|
118
|
-
subject: `Verify your ${siteName} email address`,
|
|
119
|
-
html,
|
|
120
|
-
text: `Verify your email: ${verificationLink}\n\nThis link expires in 24 hours.`,
|
|
121
|
-
});
|
|
122
|
-
if (error) {
|
|
123
|
-
serverLogger.error("Failed to send verification email (link)", { error });
|
|
124
|
-
throw error;
|
|
125
|
-
}
|
|
126
|
-
return { success: true, data };
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
serverLogger.error("Error sending verification email (link)", { error });
|
|
130
|
-
throw error;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
export async function sendPasswordResetEmailWithLink(email, resetLink) {
|
|
134
|
-
const siteName = getSiteName();
|
|
135
|
-
const html = `<!DOCTYPE html>${renderToStaticMarkup(_jsx(EmailDoc, { title: `Reset your ${siteName} password`, children: _jsxs(EmailContainer, { children: [_jsx(EmailHeader, { brandName: siteName }), _jsxs(EmailRow, { children: [_jsx("p", { style: PARA_STYLE, children: "Click the link below to reset your password. This link expires in 1 hour." }), _jsx(EmailButton, { href: resetLink, children: "Reset password" }), _jsx("p", { style: { ...PARA_STYLE, color: "#71717a" }, children: "If you didn't request this, you can safely ignore this email." })] }), _jsx(EmailFooter, { copyright: `© ${currentYear()} ${siteName}. All rights reserved.` })] }) }))}`;
|
|
136
|
-
try {
|
|
137
|
-
const { data, error } = await sendConfiguredEmail({
|
|
138
|
-
to: email,
|
|
139
|
-
subject: `Reset your ${siteName} password`,
|
|
140
|
-
html,
|
|
141
|
-
text: `Reset your password: ${resetLink}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, ignore this email.`,
|
|
142
|
-
});
|
|
143
|
-
if (error) {
|
|
144
|
-
serverLogger.error("Failed to send password reset email (link)", { error });
|
|
145
|
-
throw error;
|
|
146
|
-
}
|
|
147
|
-
return { success: true, data };
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
serverLogger.error("Error sending password reset email (link)", { error });
|
|
151
|
-
throw error;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
128
|
function renderOrderItems(items, productTitle, quantity, currency) {
|
|
155
129
|
if (items && items.length > 1) {
|
|
156
130
|
return (_jsxs(_Fragment, { children: [_jsxs("p", { style: { ...PARA_STYLE, fontWeight: 600 }, children: ["Items (", items.length, ")"] }), items.map((item, idx) => (_jsxs("p", { style: PARA_STYLE, children: [item.productTitle, " \u00D7 ", item.quantity, " \u2014", " ", _jsx(EmailBold, { children: formatCurrency(item.totalPrice, currency) })] }, item.productId ?? idx)))] }));
|
|
@@ -176,6 +150,18 @@ export async function sendOrderConfirmationEmail(params) {
|
|
|
176
150
|
subject: `Order Confirmed — ${orderId}`,
|
|
177
151
|
html,
|
|
178
152
|
text: `Hi ${userName},\n\nYour order ${orderId} has been confirmed!\n\n${itemsText}\nTotal: ${formattedTotal}\nPayment: ${paymentLabel}\nShip to: ${shippingAddress ?? "-"}\n\nView your order: ${orderUrl}\n\n© ${currentYear()} ${siteName}`,
|
|
153
|
+
}, {
|
|
154
|
+
channel: "email",
|
|
155
|
+
feature: "order_confirmation",
|
|
156
|
+
audience: "user",
|
|
157
|
+
/*
|
|
158
|
+
* Transactional: this is the buyer's receipt for money already taken.
|
|
159
|
+
* It ignores the kill switch — a site that has quietened its marketing
|
|
160
|
+
* must still confirm the orders it accepted — but it is still counted
|
|
161
|
+
* against the daily ceiling, because the ceiling exists to stay inside
|
|
162
|
+
* the provider's limit and the provider does not exempt receipts.
|
|
163
|
+
*/
|
|
164
|
+
transactional: true,
|
|
179
165
|
});
|
|
180
166
|
if (error) {
|
|
181
167
|
serverLogger.error("Failed to send order confirmation email", { error });
|
|
@@ -189,31 +175,29 @@ export async function sendOrderConfirmationEmail(params) {
|
|
|
189
175
|
return { success: false };
|
|
190
176
|
}
|
|
191
177
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
}
|
|
178
|
+
/*
|
|
179
|
+
* `sendContactEmail` was deleted here 2026-09. A contact-form submission is now
|
|
180
|
+
* a RECORD, not an email.
|
|
181
|
+
*
|
|
182
|
+
* It already wrote to `contactSubmissions` and already had a full admin inbox
|
|
183
|
+
* at `/admin/contact` with read/resolve flags — the email was a duplicate
|
|
184
|
+
* signal on top of a queue that existed. Each submission now appears in the
|
|
185
|
+
* daily digest with a `mailto:` reply link, which costs nothing and puts the
|
|
186
|
+
* reply in the operator's own mailbox rather than routing it back out through
|
|
187
|
+
* a 100/day allowance.
|
|
188
|
+
*
|
|
189
|
+
* 🛑 Two things had to change together for this to be safe, and the second is
|
|
190
|
+
* the one that bites:
|
|
191
|
+
*
|
|
192
|
+
* 1. `/api/contact` must AWAIT the Firestore write and fail the request when
|
|
193
|
+
* it fails. It was fire-and-forget `.catch()`-and-continue, with the email
|
|
194
|
+
* as the de-facto backup — so with the email gone, a dropped write would
|
|
195
|
+
* have silently lost a customer's message.
|
|
196
|
+
* 2. `getSupportEmail()` went with it. Its fallback was
|
|
197
|
+
* `"support@example.com"` and `EMAIL_SUPPORT` was never set in any
|
|
198
|
+
* runtime, so every contact email this function ever "sent" went to a
|
|
199
|
+
* domain nobody owns. That is a bug this deletion happens to fix.
|
|
200
|
+
*/
|
|
217
201
|
export async function sendDigitalCodeClaimedEmail(params) {
|
|
218
202
|
const siteName = getSiteName();
|
|
219
203
|
const siteUrl = getSiteUrl();
|
|
@@ -226,6 +210,14 @@ export async function sendDigitalCodeClaimedEmail(params) {
|
|
|
226
210
|
subject: `Your digital code for "${productTitle}" is ready`,
|
|
227
211
|
html,
|
|
228
212
|
text: `Hi ${userName},\n\nYour digital code for "${productTitle}" (Order: ${orderId}) is ready to reveal.\n\nVisit your order: ${orderUrl}\n\nFor security, the code is only shown inside your ${siteName} account.\n\n© ${currentYear()} ${siteName}`,
|
|
213
|
+
}, {
|
|
214
|
+
channel: "email",
|
|
215
|
+
feature: "digital_code_claimed",
|
|
216
|
+
audience: "user",
|
|
217
|
+
// Transactional: the buyer has paid and this is how they learn the
|
|
218
|
+
// thing they bought is available to collect. Suppressing it would be
|
|
219
|
+
// taking money and saying nothing.
|
|
220
|
+
transactional: true,
|
|
229
221
|
});
|
|
230
222
|
if (error) {
|
|
231
223
|
serverLogger.error("Failed to send digital code claimed email", { error });
|
|
@@ -239,35 +231,3 @@ export async function sendDigitalCodeClaimedEmail(params) {
|
|
|
239
231
|
return { success: false };
|
|
240
232
|
}
|
|
241
233
|
}
|
|
242
|
-
export async function sendSiteSettingsChangedEmail(params) {
|
|
243
|
-
const siteName = getSiteName();
|
|
244
|
-
const siteUrl = getSiteUrl();
|
|
245
|
-
const { adminEmails, changedByEmail, changedFields } = params;
|
|
246
|
-
if (adminEmails.length === 0)
|
|
247
|
-
return { success: false };
|
|
248
|
-
const settingsUrl = `${siteUrl}/admin/site-settings`;
|
|
249
|
-
const timestamp = formatDateTime(nowMs());
|
|
250
|
-
const html = `<!DOCTYPE html>${renderToStaticMarkup(_jsx(EmailDoc, { title: "Site settings changed", children: _jsxs(EmailContainer, { children: [_jsx(EmailHeader, { brandName: siteName, children: "Site Settings Changed \u2699\uFE0F" }), _jsxs(EmailRow, { children: [_jsxs("p", { style: PARA_STYLE, children: [_jsx(EmailBold, { children: changedByEmail }), " has updated the following site settings:"] }), _jsxs("p", { style: PARA_STYLE, children: [_jsx("span", { style: LABEL_STYLE, children: "Changed fields:" }), " ", _jsx("span", { style: VALUE_STYLE, children: changedFields.join(", ") })] }), _jsxs("p", { style: PARA_STYLE, children: [_jsx("span", { style: LABEL_STYLE, children: "Changed by:" }), " ", _jsx("span", { style: VALUE_STYLE, children: changedByEmail })] }), _jsxs("p", { style: PARA_STYLE, children: [_jsx("span", { style: LABEL_STYLE, children: "Timestamp:" }), " ", _jsx("span", { style: VALUE_STYLE, children: timestamp })] }), _jsx(EmailDivider, { spacing: "md" }), _jsx(EmailButton, { href: settingsUrl, children: "View settings" })] }), _jsx(EmailFooter, { copyright: `© ${currentYear()} ${siteName}. Automated notification — do not reply.` })] }) }))}`;
|
|
251
|
-
try {
|
|
252
|
-
const { data, error } = await sendConfiguredEmail({
|
|
253
|
-
to: adminEmails,
|
|
254
|
-
subject: `Site settings updated by ${changedByEmail}`,
|
|
255
|
-
html,
|
|
256
|
-
text: `Site settings updated by ${changedByEmail}\n\nChanged fields: ${changedFields.join(", ")}\n\nView at: ${settingsUrl}`,
|
|
257
|
-
});
|
|
258
|
-
if (error) {
|
|
259
|
-
serverLogger.error("Failed to send settings change notification email", {
|
|
260
|
-
error,
|
|
261
|
-
});
|
|
262
|
-
return { success: false };
|
|
263
|
-
}
|
|
264
|
-
return { success: true, data };
|
|
265
|
-
}
|
|
266
|
-
catch (error) {
|
|
267
|
-
void normalizeError(error);
|
|
268
|
-
serverLogger.error("Error sending settings change notification email", {
|
|
269
|
-
error,
|
|
270
|
-
});
|
|
271
|
-
return { success: false };
|
|
272
|
-
}
|
|
273
|
-
}
|
|
@@ -76,6 +76,17 @@ export const NOTIFICATION_EMAIL_TEMPLATES = {
|
|
|
76
76
|
// No CTA for the same reason — a system announcement has no record behind
|
|
77
77
|
// it, and `resolveNotificationActionUrl` correctly returns undefined.
|
|
78
78
|
system: { lead: "A message from the team." },
|
|
79
|
+
/*
|
|
80
|
+
* Both types are email-INELIGIBLE (`EMAIL_ELIGIBLE_TYPES`), so neither
|
|
81
|
+
* renders in the ordinary course — but they still need real entries rather
|
|
82
|
+
* than placeholders, because `support_ticket_update` DOES reach this
|
|
83
|
+
* renderer whenever a staff replier ticks "also email the user".
|
|
84
|
+
* `scam_report_update` cannot reach it today; it gets an honest entry anyway
|
|
85
|
+
* so that a future override path finds a written template instead of
|
|
86
|
+
* inventing one under time pressure.
|
|
87
|
+
*/
|
|
88
|
+
support_ticket_update: { lead: "There's an update on your support ticket.", cta: "View ticket" },
|
|
89
|
+
scam_report_update: { lead: "There's an update on a scam report you filed.", cta: "View report" },
|
|
79
90
|
};
|
|
80
91
|
/**
|
|
81
92
|
* Render one notification as an email.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { BaseRepository } from "../../../providers/db-firebase";
|
|
2
|
+
import { type AuthMailCooldownDocument, type AuthMailPurpose, type MessageBudgetDocument } from "../schemas/firestore";
|
|
3
|
+
import type { MessageChannel } from "../../../_internal/shared/features/messaging/config";
|
|
4
|
+
/**
|
|
5
|
+
* `en-CA` because it formats as YYYY-MM-DD natively — the alternative is
|
|
6
|
+
* hand-assembling parts from `formatToParts`, which is the same thing with
|
|
7
|
+
* more places to get it wrong.
|
|
8
|
+
*/
|
|
9
|
+
export declare function budgetDateString(now?: Date): string;
|
|
10
|
+
export declare class MessageBudgetRepository extends BaseRepository<MessageBudgetDocument> {
|
|
11
|
+
constructor();
|
|
12
|
+
/**
|
|
13
|
+
* Claim one unit of today's allowance for `channel`.
|
|
14
|
+
*
|
|
15
|
+
* ## Reserve-then-send, on purpose
|
|
16
|
+
*
|
|
17
|
+
* The unit is consumed BEFORE the provider call, so a send that then fails
|
|
18
|
+
* still costs the budget. That is the conservative direction and it is the
|
|
19
|
+
* right one here: on an 80/day allowance, over-counting means we stop a
|
|
20
|
+
* little early, while under-counting means the provider starts rejecting and
|
|
21
|
+
* every subsequent message — including the transactional ones — is lost.
|
|
22
|
+
*
|
|
23
|
+
* There is deliberately no `release()`. A failing channel is already handled
|
|
24
|
+
* one layer up by the circuit breaker in
|
|
25
|
+
* `_internal/server/notifications/channel-health.ts`, which halts the channel
|
|
26
|
+
* after 3 failures; adding a compensating decrement here would mean a
|
|
27
|
+
* partially-failed send could double-refund, which is exactly the kind of
|
|
28
|
+
* subtle accounting bug a quota guard must not have.
|
|
29
|
+
*
|
|
30
|
+
* ## Why a single document and not a sharded counter
|
|
31
|
+
*
|
|
32
|
+
* Firestore sustains roughly one write per second on one document, so a
|
|
33
|
+
* concurrent burst would contend. There is no such burst left: the
|
|
34
|
+
* `EMAIL_ELIGIBLE_TYPES` gate in `sendNotification` returns before this
|
|
35
|
+
* function is ever reached for the two fan-outs that had one (`bid_lost`,
|
|
36
|
+
* 50-way, and the old scam-report employee blast, 100-way), and every
|
|
37
|
+
* remaining email-eligible caller loops sequentially with `await`.
|
|
38
|
+
*
|
|
39
|
+
* 🛑 If a concurrent fan-out of an email-ELIGIBLE type is ever added —
|
|
40
|
+
* `Promise.all`/`Promise.allSettled` over sends is the tell — this must be
|
|
41
|
+
* sharded before it merges. `scripts/audit-unguarded-send.mjs` fails the
|
|
42
|
+
* build on that pattern precisely so the decision cannot be made by
|
|
43
|
+
* accident.
|
|
44
|
+
*/
|
|
45
|
+
reserveSend(channel: MessageChannel, ceiling: number, now?: Date): Promise<{
|
|
46
|
+
allowed: boolean;
|
|
47
|
+
count: number;
|
|
48
|
+
ceiling: number;
|
|
49
|
+
}>;
|
|
50
|
+
/** Read-only, for the daily digest. Never creates the document. */
|
|
51
|
+
getCount(channel: MessageChannel, now?: Date): Promise<number>;
|
|
52
|
+
}
|
|
53
|
+
export declare class AuthMailCooldownRepository extends BaseRepository<AuthMailCooldownDocument> {
|
|
54
|
+
constructor();
|
|
55
|
+
/**
|
|
56
|
+
* Has this address already asked for this kind of mail recently?
|
|
57
|
+
*
|
|
58
|
+
* 🛑 `emailHash` must already be hashed by the caller — this function never
|
|
59
|
+
* sees a real address, and the collection stores none. Passing a plaintext
|
|
60
|
+
* email here would put an unencrypted identity into a collection with no PII
|
|
61
|
+
* handling, which is exactly the leak `SUPPORT_TICKET_PII_FIELDS` and
|
|
62
|
+
* friends exist to prevent.
|
|
63
|
+
*
|
|
64
|
+
* Transactional check-and-set rather than read-then-write: two tabs hitting
|
|
65
|
+
* "resend" at once must not both pass.
|
|
66
|
+
*/
|
|
67
|
+
checkAndSet(purpose: AuthMailPurpose, emailHash: string, now?: Date): Promise<{
|
|
68
|
+
allowed: boolean;
|
|
69
|
+
retryAfterSeconds: number;
|
|
70
|
+
}>;
|
|
71
|
+
}
|
|
72
|
+
export declare const messageBudgetRepository: MessageBudgetRepository;
|
|
73
|
+
export declare const authMailCooldownRepository: AuthMailCooldownRepository;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { BaseRepository } from "../../../providers/db-firebase";
|
|
2
|
+
import { AUTH_MAIL_COOLDOWN_COLLECTION, AUTH_MAIL_COOLDOWN_MS, MESSAGE_BUDGET_COLLECTION, MESSAGE_BUDGET_FIELDS, } from "../schemas/firestore";
|
|
3
|
+
/**
|
|
4
|
+
* Reporting timezone for "which day is it". Fixed rather than server-local
|
|
5
|
+
* because a Vercel lambda runs in UTC and a Firebase Function in whatever
|
|
6
|
+
* region it was deployed to — two runtimes disagreeing about the date would
|
|
7
|
+
* silently give the site two partial budgets around midnight IST.
|
|
8
|
+
*
|
|
9
|
+
* Matches the daily digest's own cron timezone, so "today" means the same
|
|
10
|
+
* thing in the counter and in the report of the counter.
|
|
11
|
+
*/
|
|
12
|
+
const BUDGET_TIMEZONE = "Asia/Kolkata";
|
|
13
|
+
/**
|
|
14
|
+
* `en-CA` because it formats as YYYY-MM-DD natively — the alternative is
|
|
15
|
+
* hand-assembling parts from `formatToParts`, which is the same thing with
|
|
16
|
+
* more places to get it wrong.
|
|
17
|
+
*/
|
|
18
|
+
export function budgetDateString(now = new Date()) {
|
|
19
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
20
|
+
timeZone: BUDGET_TIMEZONE,
|
|
21
|
+
year: "numeric",
|
|
22
|
+
month: "2-digit",
|
|
23
|
+
day: "2-digit",
|
|
24
|
+
}).format(now);
|
|
25
|
+
}
|
|
26
|
+
export class MessageBudgetRepository extends BaseRepository {
|
|
27
|
+
constructor() {
|
|
28
|
+
super(MESSAGE_BUDGET_COLLECTION);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Claim one unit of today's allowance for `channel`.
|
|
32
|
+
*
|
|
33
|
+
* ## Reserve-then-send, on purpose
|
|
34
|
+
*
|
|
35
|
+
* The unit is consumed BEFORE the provider call, so a send that then fails
|
|
36
|
+
* still costs the budget. That is the conservative direction and it is the
|
|
37
|
+
* right one here: on an 80/day allowance, over-counting means we stop a
|
|
38
|
+
* little early, while under-counting means the provider starts rejecting and
|
|
39
|
+
* every subsequent message — including the transactional ones — is lost.
|
|
40
|
+
*
|
|
41
|
+
* There is deliberately no `release()`. A failing channel is already handled
|
|
42
|
+
* one layer up by the circuit breaker in
|
|
43
|
+
* `_internal/server/notifications/channel-health.ts`, which halts the channel
|
|
44
|
+
* after 3 failures; adding a compensating decrement here would mean a
|
|
45
|
+
* partially-failed send could double-refund, which is exactly the kind of
|
|
46
|
+
* subtle accounting bug a quota guard must not have.
|
|
47
|
+
*
|
|
48
|
+
* ## Why a single document and not a sharded counter
|
|
49
|
+
*
|
|
50
|
+
* Firestore sustains roughly one write per second on one document, so a
|
|
51
|
+
* concurrent burst would contend. There is no such burst left: the
|
|
52
|
+
* `EMAIL_ELIGIBLE_TYPES` gate in `sendNotification` returns before this
|
|
53
|
+
* function is ever reached for the two fan-outs that had one (`bid_lost`,
|
|
54
|
+
* 50-way, and the old scam-report employee blast, 100-way), and every
|
|
55
|
+
* remaining email-eligible caller loops sequentially with `await`.
|
|
56
|
+
*
|
|
57
|
+
* 🛑 If a concurrent fan-out of an email-ELIGIBLE type is ever added —
|
|
58
|
+
* `Promise.all`/`Promise.allSettled` over sends is the tell — this must be
|
|
59
|
+
* sharded before it merges. `scripts/audit-unguarded-send.mjs` fails the
|
|
60
|
+
* build on that pattern precisely so the decision cannot be made by
|
|
61
|
+
* accident.
|
|
62
|
+
*/
|
|
63
|
+
async reserveSend(channel, ceiling, now = new Date()) {
|
|
64
|
+
const date = budgetDateString(now);
|
|
65
|
+
const docRef = this.db.collection(this.collection).doc(`${channel}_${date}`);
|
|
66
|
+
return this.db.runTransaction(async (tx) => {
|
|
67
|
+
const snap = await tx.get(docRef);
|
|
68
|
+
const current = snap.exists
|
|
69
|
+
? (snap.data()?.[MESSAGE_BUDGET_FIELDS.COUNT] ?? 0)
|
|
70
|
+
: 0;
|
|
71
|
+
if (current >= ceiling) {
|
|
72
|
+
return { allowed: false, count: current, ceiling };
|
|
73
|
+
}
|
|
74
|
+
const next = current + 1;
|
|
75
|
+
tx.set(docRef, {
|
|
76
|
+
[MESSAGE_BUDGET_FIELDS.CHANNEL]: channel,
|
|
77
|
+
[MESSAGE_BUDGET_FIELDS.DATE]: date,
|
|
78
|
+
[MESSAGE_BUDGET_FIELDS.COUNT]: next,
|
|
79
|
+
[MESSAGE_BUDGET_FIELDS.UPDATED_AT]: now,
|
|
80
|
+
}, { merge: true });
|
|
81
|
+
return { allowed: true, count: next, ceiling };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Read-only, for the daily digest. Never creates the document. */
|
|
85
|
+
async getCount(channel, now = new Date()) {
|
|
86
|
+
const snap = await this.db
|
|
87
|
+
.collection(this.collection)
|
|
88
|
+
.doc(`${channel}_${budgetDateString(now)}`)
|
|
89
|
+
.get();
|
|
90
|
+
return snap.exists
|
|
91
|
+
? (snap.data()?.[MESSAGE_BUDGET_FIELDS.COUNT] ?? 0)
|
|
92
|
+
: 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export class AuthMailCooldownRepository extends BaseRepository {
|
|
96
|
+
constructor() {
|
|
97
|
+
super(AUTH_MAIL_COOLDOWN_COLLECTION);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Has this address already asked for this kind of mail recently?
|
|
101
|
+
*
|
|
102
|
+
* 🛑 `emailHash` must already be hashed by the caller — this function never
|
|
103
|
+
* sees a real address, and the collection stores none. Passing a plaintext
|
|
104
|
+
* email here would put an unencrypted identity into a collection with no PII
|
|
105
|
+
* handling, which is exactly the leak `SUPPORT_TICKET_PII_FIELDS` and
|
|
106
|
+
* friends exist to prevent.
|
|
107
|
+
*
|
|
108
|
+
* Transactional check-and-set rather than read-then-write: two tabs hitting
|
|
109
|
+
* "resend" at once must not both pass.
|
|
110
|
+
*/
|
|
111
|
+
async checkAndSet(purpose, emailHash, now = new Date()) {
|
|
112
|
+
const docRef = this.db
|
|
113
|
+
.collection(this.collection)
|
|
114
|
+
.doc(`${purpose}_${emailHash}`);
|
|
115
|
+
return this.db.runTransaction(async (tx) => {
|
|
116
|
+
const snap = await tx.get(docRef);
|
|
117
|
+
const lastRequestedAt = snap.exists
|
|
118
|
+
? (snap.data()?.lastRequestedAt?.toDate?.() ?? null)
|
|
119
|
+
: null;
|
|
120
|
+
// `Infinity` when there is no prior request, so the single comparison
|
|
121
|
+
// below covers both "never asked" and "asked long enough ago" — flatter
|
|
122
|
+
// than nesting the null check around the elapsed check.
|
|
123
|
+
const elapsed = lastRequestedAt
|
|
124
|
+
? now.getTime() - lastRequestedAt.getTime()
|
|
125
|
+
: Number.POSITIVE_INFINITY;
|
|
126
|
+
if (elapsed < AUTH_MAIL_COOLDOWN_MS) {
|
|
127
|
+
return {
|
|
128
|
+
allowed: false,
|
|
129
|
+
retryAfterSeconds: Math.ceil((AUTH_MAIL_COOLDOWN_MS - elapsed) / 1000),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
tx.set(docRef, { purpose, lastRequestedAt: now }, { merge: true });
|
|
133
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export const messageBudgetRepository = new MessageBudgetRepository();
|
|
138
|
+
export const authMailCooldownRepository = new AuthMailCooldownRepository();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daily send counter.
|
|
3
|
+
*
|
|
4
|
+
* One document per channel per calendar day. It exists because the only other
|
|
5
|
+
* rate limiter in this codebase cannot do this job: `appkit/src/security/rate-limit.ts`
|
|
6
|
+
* falls back to a module-scope `Map` when Upstash is unconfigured (it is), so
|
|
7
|
+
* on Vercel the count is per-lambda-instance and resets on every cold start.
|
|
8
|
+
* That is fine for "slow down this IP" and useless for "we have 100 of these
|
|
9
|
+
* to spend today".
|
|
10
|
+
*
|
|
11
|
+
* Firestore is the only shared, durable counter this project already pays for.
|
|
12
|
+
* The shape is copied deliberately from `SmsCounterDocument` — same
|
|
13
|
+
* date-keyed-document, same transactional increment — because that pattern has
|
|
14
|
+
* been in production on the OTP path and there is no reason to invent a second
|
|
15
|
+
* one.
|
|
16
|
+
*/
|
|
17
|
+
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
18
|
+
import type { MessageChannel } from "../../../_internal/shared/features/messaging/config";
|
|
19
|
+
/**
|
|
20
|
+
* The document ID is `{channel}_{YYYY-MM-DD}` — composite rather than a
|
|
21
|
+
* subcollection so the whole thing is one `.doc()` lookup with no query, no
|
|
22
|
+
* index, and no fan-out. `id` itself comes from `BaseDocument`.
|
|
23
|
+
*/
|
|
24
|
+
export interface MessageBudgetDocument extends BaseDocument {
|
|
25
|
+
channel: MessageChannel;
|
|
26
|
+
/** Calendar date in the site's reporting timezone — YYYY-MM-DD. */
|
|
27
|
+
date: string;
|
|
28
|
+
/** Messages RESERVED today. Reserved, not delivered — see `reserveSend`. */
|
|
29
|
+
count: number;
|
|
30
|
+
}
|
|
31
|
+
export declare const MESSAGE_BUDGET_COLLECTION: "messageBudget";
|
|
32
|
+
export declare const MESSAGE_BUDGET_FIELDS: {
|
|
33
|
+
readonly CHANNEL: "channel";
|
|
34
|
+
readonly DATE: "date";
|
|
35
|
+
readonly COUNT: "count";
|
|
36
|
+
readonly UPDATED_AT: "updatedAt";
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Cooldown ledger for the Firebase-side auth mails (password reset, email
|
|
40
|
+
* verification), which never touch this app's server and so cannot be guarded
|
|
41
|
+
* where the others are.
|
|
42
|
+
*
|
|
43
|
+
* 🛑 `id` is a HASH of the email address, never the address itself. This
|
|
44
|
+
* collection is not PII-encrypted and does not need to be — the whole point is
|
|
45
|
+
* that it stores no identity, only the fact that *something* asked recently.
|
|
46
|
+
*/
|
|
47
|
+
export interface AuthMailCooldownDocument extends BaseDocument {
|
|
48
|
+
/** Document ID is `{purpose}_{hmac(email)}`; `id` comes from BaseDocument. */
|
|
49
|
+
purpose: AuthMailPurpose;
|
|
50
|
+
lastRequestedAt: Date;
|
|
51
|
+
}
|
|
52
|
+
export declare const AUTH_MAIL_COOLDOWN_COLLECTION: "authMailCooldowns";
|
|
53
|
+
export declare const AUTH_MAIL_PURPOSE_VALUES: readonly ["password_reset", "verify_email"];
|
|
54
|
+
export type AuthMailPurpose = (typeof AUTH_MAIL_PURPOSE_VALUES)[number];
|
|
55
|
+
/**
|
|
56
|
+
* How long a given address must wait between requests of the same kind.
|
|
57
|
+
*
|
|
58
|
+
* 15 minutes matches the two cooldowns already in this codebase — the checkout
|
|
59
|
+
* value OTP and the SMS OTP — so a user who hits two of them in one session
|
|
60
|
+
* meets one consistent rule rather than three different ones.
|
|
61
|
+
*/
|
|
62
|
+
export declare const AUTH_MAIL_COOLDOWN_MS: number;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daily send counter.
|
|
3
|
+
*
|
|
4
|
+
* One document per channel per calendar day. It exists because the only other
|
|
5
|
+
* rate limiter in this codebase cannot do this job: `appkit/src/security/rate-limit.ts`
|
|
6
|
+
* falls back to a module-scope `Map` when Upstash is unconfigured (it is), so
|
|
7
|
+
* on Vercel the count is per-lambda-instance and resets on every cold start.
|
|
8
|
+
* That is fine for "slow down this IP" and useless for "we have 100 of these
|
|
9
|
+
* to spend today".
|
|
10
|
+
*
|
|
11
|
+
* Firestore is the only shared, durable counter this project already pays for.
|
|
12
|
+
* The shape is copied deliberately from `SmsCounterDocument` — same
|
|
13
|
+
* date-keyed-document, same transactional increment — because that pattern has
|
|
14
|
+
* been in production on the OTP path and there is no reason to invent a second
|
|
15
|
+
* one.
|
|
16
|
+
*/
|
|
17
|
+
export const MESSAGE_BUDGET_COLLECTION = "messageBudget";
|
|
18
|
+
export const MESSAGE_BUDGET_FIELDS = {
|
|
19
|
+
CHANNEL: "channel",
|
|
20
|
+
DATE: "date",
|
|
21
|
+
COUNT: "count",
|
|
22
|
+
UPDATED_AT: "updatedAt",
|
|
23
|
+
};
|
|
24
|
+
export const AUTH_MAIL_COOLDOWN_COLLECTION = "authMailCooldowns";
|
|
25
|
+
export const AUTH_MAIL_PURPOSE_VALUES = [
|
|
26
|
+
"password_reset",
|
|
27
|
+
"verify_email",
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* How long a given address must wait between requests of the same kind.
|
|
31
|
+
*
|
|
32
|
+
* 15 minutes matches the two cooldowns already in this codebase — the checkout
|
|
33
|
+
* value OTP and the SMS OTP — so a user who hits two of them in one session
|
|
34
|
+
* meets one consistent rule rather than three different ones.
|
|
35
|
+
*/
|
|
36
|
+
export const AUTH_MAIL_COOLDOWN_MS = 15 * 60 * 1000;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: Shared tester sandbox — the LOCKED cart line a settled auction leaves behind.
|
|
3
|
+
* WHAT: One cart for user-tester-qa holding the win from auction-tester-sandbox-won.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 WHY THIS FILE HAD TO EXIST.
|
|
6
|
+
*
|
|
7
|
+
* The sandbox seeded a won auction, its winning bid, and a delivered order made
|
|
8
|
+
* from it — the shape settlement produced BEFORE Root Cause #60, when it wrote an
|
|
9
|
+
* order document directly. That order was in a shape no orders list could render
|
|
10
|
+
* and no checkout could accept, so a winner had no way to pay at all; the fix
|
|
11
|
+
* routed a win through the CART as a locked line instead.
|
|
12
|
+
*
|
|
13
|
+
* The fixtures were never updated to match. So `win-auction` — the case whose
|
|
14
|
+
* label is literally "creates a payable locked cart line, not a stuck order" —
|
|
15
|
+
* could not pass: /user/bids offered "Pay now", it led to /checkout?lane=auction,
|
|
16
|
+
* and the cart was empty. Found by the tester 2026-09-06, which is exactly the
|
|
17
|
+
* kind of thing a case can only prove by being run.
|
|
18
|
+
*
|
|
19
|
+
* The delivered order fixture STAYS. It is the "win already completed" state and
|
|
20
|
+
* other cases read it. This adds the missing "win awaiting payment" state.
|
|
21
|
+
*
|
|
22
|
+
* 🛑 `isAuctionWin` is not decoration. `laneOf()` keys on `isAuctionWin || bidId`
|
|
23
|
+
* and ignores `listingType` and `locked` entirely — a line without it lands in the
|
|
24
|
+
* STANDARD lane, so the Won Auctions tab stays empty while a non-removable line
|
|
25
|
+
* sits in the ordinary cart. Both fields are set here so the lane is unambiguous.
|
|
26
|
+
*
|
|
27
|
+
* @tag domain:tester
|
|
28
|
+
* @tag layer:seed
|
|
29
|
+
* @tag pattern:none
|
|
30
|
+
* @tag access:server-only
|
|
31
|
+
* @tag consumers:seed/index.ts,seed/manifest.ts
|
|
32
|
+
* @tag sideEffects:none
|
|
33
|
+
*/
|
|
34
|
+
import { testDataExpiresAt } from "./tester-ttl";
|
|
35
|
+
const NOW = new Date();
|
|
36
|
+
const hoursAgo = (n) => new Date(NOW.getTime() - n * 3600000);
|
|
37
|
+
/** The cart document id IS the user id — one cart per account. */
|
|
38
|
+
export const cartsTesterSeedData = [
|
|
39
|
+
{
|
|
40
|
+
id: "user-tester-qa",
|
|
41
|
+
userId: "user-tester-qa",
|
|
42
|
+
items: [
|
|
43
|
+
{
|
|
44
|
+
itemId: "cartitem-tester-sandbox-auction-won",
|
|
45
|
+
productId: "auction-tester-sandbox-won",
|
|
46
|
+
productTitle: "Test Auction — Already Won",
|
|
47
|
+
price: 15000,
|
|
48
|
+
currency: "INR",
|
|
49
|
+
quantity: 1,
|
|
50
|
+
storeId: "store-tester-qa-seller",
|
|
51
|
+
storeName: "Tester QA Seller",
|
|
52
|
+
listingType: "auction",
|
|
53
|
+
// Both, deliberately — see the lane note in the header.
|
|
54
|
+
isAuctionWin: true,
|
|
55
|
+
bidId: "bid-tester-sandbox-won-tester-qa",
|
|
56
|
+
// A win is non-removable and fixed-quantity: settlement is the only writer
|
|
57
|
+
// of this line, and declining to pay is handled by the forfeit sweep rather
|
|
58
|
+
// than by the buyer deleting it.
|
|
59
|
+
locked: true,
|
|
60
|
+
lockedPrice: 15000,
|
|
61
|
+
addedAt: hoursAgo(2),
|
|
62
|
+
updatedAt: hoursAgo(2),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
isTestData: true,
|
|
66
|
+
testDataExpiresAt: testDataExpiresAt(),
|
|
67
|
+
createdAt: hoursAgo(2),
|
|
68
|
+
updatedAt: hoursAgo(2),
|
|
69
|
+
},
|
|
70
|
+
];
|
|
@@ -6,5 +6,6 @@ export { blogTesterSeedData } from "./blog-tester-seed-data";
|
|
|
6
6
|
export { eventsTesterSeedData } from "./events-tester-seed-data";
|
|
7
7
|
export { couponsTesterSeedData } from "./coupons-tester-seed-data";
|
|
8
8
|
export { bidsTesterSeedData } from "./bids-tester-seed-data";
|
|
9
|
+
export { cartsTesterSeedData } from "./carts-tester-seed-data";
|
|
9
10
|
export { ordersTesterSeedData } from "./orders-tester-seed-data";
|
|
10
11
|
export { offersTesterSeedData } from "./offers-tester-seed-data";
|
|
@@ -6,5 +6,6 @@ export { blogTesterSeedData } from "./blog-tester-seed-data";
|
|
|
6
6
|
export { eventsTesterSeedData } from "./events-tester-seed-data";
|
|
7
7
|
export { couponsTesterSeedData } from "./coupons-tester-seed-data";
|
|
8
8
|
export { bidsTesterSeedData } from "./bids-tester-seed-data";
|
|
9
|
+
export { cartsTesterSeedData } from "./carts-tester-seed-data";
|
|
9
10
|
export { ordersTesterSeedData } from "./orders-tester-seed-data";
|
|
10
11
|
export { offersTesterSeedData } from "./offers-tester-seed-data";
|