@mohasinac/appkit 4.35.0 → 4.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +28 -22
  2. package/dist/_internal/server/features/orders/actions.js +58 -49
  3. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  4. package/dist/_internal/server/features/site-settings/adapters.js +8 -0
  5. package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
  6. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
  7. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
  8. package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
  9. package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
  10. package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
  11. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
  12. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
  13. package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
  14. package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
  15. package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
  16. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
  17. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
  18. package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
  19. package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
  20. package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
  21. package/dist/_internal/server/notifications/send-guard.js +142 -0
  22. package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
  23. package/dist/_internal/shared/features/messaging/config.js +44 -0
  24. package/dist/_internal/shared/features/notifications/action-url.js +9 -0
  25. package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
  26. package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
  27. package/dist/constants/api-endpoints.d.ts +30 -0
  28. package/dist/constants/api-endpoints.js +10 -0
  29. package/dist/constants/notification-types.d.ts +1 -1
  30. package/dist/features/admin/actions/notification-actions.d.ts +20 -0
  31. package/dist/features/admin/actions/notification-actions.js +43 -1
  32. package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
  33. package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
  34. package/dist/features/admin/schemas/firestore.d.ts +32 -2
  35. package/dist/features/admin/schemas/firestore.js +28 -0
  36. package/dist/features/auth/actions/profile-actions.d.ts +14 -1
  37. package/dist/features/auth/actions/profile-actions.js +16 -2
  38. package/dist/features/auth/hooks/useAuth.d.ts +5 -0
  39. package/dist/features/auth/hooks/useAuth.js +42 -0
  40. package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
  41. package/dist/features/contact/email.d.ts +30 -26
  42. package/dist/features/contact/email.js +65 -105
  43. package/dist/features/email/notification-templates.js +11 -0
  44. package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
  45. package/dist/features/messaging/repository/message-budget.repository.js +138 -0
  46. package/dist/features/messaging/schemas/firestore.d.ts +62 -0
  47. package/dist/features/messaging/schemas/firestore.js +36 -0
  48. package/dist/features/tester/seed-data/carts-tester-seed-data.d.ts +3 -0
  49. package/dist/features/tester/seed-data/carts-tester-seed-data.js +70 -0
  50. package/dist/features/tester/seed-data/index.d.ts +1 -0
  51. package/dist/features/tester/seed-data/index.js +1 -0
  52. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
  53. package/dist/seed/cart-seed-data.js +11 -0
  54. package/dist/seed/index.d.ts +1 -1
  55. package/dist/seed/index.js +1 -1
  56. package/dist/seed/manifest.js +2 -2
  57. package/dist/seed/notifications-seed-data.js +5 -1
  58. package/dist/seed/site-settings-seed-data.js +39 -10
  59. package/dist/server.d.ts +3 -5
  60. package/dist/server.js +27 -13
  61. package/package.json +1 -1
  62. package/scripts/seed-cli.mjs +752 -752
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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, formatDateTime, nowMs, } from "../../utils";
20
- import { EmailBold, EmailButton, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailLink, EmailRow, } from "../email/primitives";
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
- export async function sendEmail(opts) {
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
- export async function sendContactEmail(params) {
193
- const siteName = getSiteName();
194
- const supportEmail = getSupportEmail();
195
- const { name, email, subject, message } = params;
196
- const html = `<!DOCTYPE html>${renderToStaticMarkup(_jsx(EmailDoc, { title: "New contact message", children: _jsxs(EmailContainer, { children: [_jsx(EmailHeader, { brandName: siteName, children: "New Contact Message" }), _jsxs(EmailRow, { children: [_jsxs("p", { style: PARA_STYLE, children: [_jsx("span", { style: LABEL_STYLE, children: "From:" }), " ", _jsxs("span", { style: VALUE_STYLE, children: [name, " <", _jsx(EmailLink, { href: `mailto:${email}`, children: email }), ">"] })] }), _jsxs("p", { style: PARA_STYLE, children: [_jsx("span", { style: LABEL_STYLE, children: "Subject:" }), " ", _jsx("span", { style: VALUE_STYLE, children: subject })] }), _jsx(EmailDivider, { spacing: "md" }), _jsx("p", { style: { ...PARA_STYLE, whiteSpace: "pre-wrap" }, children: message })] }), _jsx(EmailFooter, { copyright: `© ${currentYear()} ${siteName}` })] }) }))}`;
197
- try {
198
- const { data, error } = await sendConfiguredEmail({
199
- to: supportEmail,
200
- replyTo: email,
201
- subject: `[Contact] ${subject}`,
202
- html,
203
- text: `From: ${name} <${email}>\nSubject: ${subject}\n\n${message}`,
204
- });
205
- if (error) {
206
- serverLogger.error("Failed to send contact email", { error });
207
- return { success: false };
208
- }
209
- return { success: true, data };
210
- }
211
- catch (error) {
212
- void normalizeError(error);
213
- serverLogger.error("Error sending contact email", { error });
214
- return { success: false };
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,3 @@
1
+ import type { CartDocument } from "../../cart/schemas/firestore";
2
+ /** The cart document id IS the user id — one cart per account. */
3
+ export declare const cartsTesterSeedData: Partial<CartDocument>[];
@@ -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";