@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
|
@@ -13,6 +13,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
13
13
|
* unbounded-ish order scan below is safe against CLAUDE.md Rule #6's 10s
|
|
14
14
|
* sync ceiling — it has the Function's much larger budget.
|
|
15
15
|
*/
|
|
16
|
+
import { Fragment } from "react";
|
|
16
17
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
17
18
|
import { getAdminDb } from "../../../../providers/db-firebase";
|
|
18
19
|
import { sendEmail } from "../../../../features/contact/email";
|
|
@@ -20,14 +21,35 @@ import { siteSettingsRepository } from "../../../../features/admin/repository/si
|
|
|
20
21
|
import { ORDER_COLLECTION, OrderStatusValues } from "../../../../features/orders/schemas/firestore";
|
|
21
22
|
import { PRODUCT_COLLECTION, ProductStatusValues } from "../../../../features/products/schemas/firestore";
|
|
22
23
|
import { ORDER_FIELDS, PRODUCT_FIELDS } from "../../../../constants/field-names";
|
|
23
|
-
import {
|
|
24
|
+
import { CONTACT_SUBMISSIONS_COLLECTION } from "../../../../core/contact-submissions.repository";
|
|
25
|
+
import { NEWSLETTER_SUBSCRIBERS_COLLECTION } from "../../../../core/newsletter.repository";
|
|
26
|
+
import { SUPPORT_TICKET_COLLECTION, SUPPORT_TICKET_FIELDS, TicketStatusValues, } from "../../../../features/support/schemas/firestore";
|
|
27
|
+
import { SCAMMER_COLLECTION, SCAMMER_FIELDS } from "../../../../features/scams/schemas/firestore";
|
|
28
|
+
import { ADMIN_AUDIT_LOG_COLLECTION } from "../../../../features/audit-log/schemas/firestore";
|
|
29
|
+
import { ADMIN_NOTIFICATIONS_COLLECTION } from "../../../../features/store-extensions/schemas/rbac";
|
|
30
|
+
import { EmailBold, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailLink, EmailRow, } from "../../../../features/email/primitives";
|
|
24
31
|
import { normalizeError } from "../../../../errors/normalize";
|
|
25
32
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
/*
|
|
34
|
+
* `createdAt` and `isRead` on collections with no feature-specific field-name
|
|
35
|
+
* constant — contactSubmissions, newsletterSubscribers, adminAuditLog,
|
|
36
|
+
* adminNotifications.
|
|
37
|
+
*
|
|
38
|
+
* audit-field-name-ok: these are BaseDocument fields common to every
|
|
39
|
+
* collection, not per-feature names. Borrowing ORDER_FIELDS.CREATED_AT here
|
|
40
|
+
* would assert a relationship to the orders schema that does not exist, so a
|
|
41
|
+
* rename there would appear to affect queries it has nothing to do with. Named
|
|
42
|
+
* once, locally, rather than repeated as bare literals at ten call sites.
|
|
43
|
+
*/
|
|
44
|
+
const CREATED_AT = "createdAt";
|
|
45
|
+
const IS_READ = "isRead";
|
|
26
46
|
/** Statuses that shouldn't count toward revenue for the day. */
|
|
27
47
|
const NON_REVENUE_STATUSES = new Set([
|
|
28
48
|
OrderStatusValues.CANCELLED,
|
|
29
49
|
OrderStatusValues.REFUNDED,
|
|
30
50
|
]);
|
|
51
|
+
/** How many contact messages the digest lists in full before summarising the rest. */
|
|
52
|
+
const CONTACT_PREVIEW_LIMIT = 20;
|
|
31
53
|
function currency(amount) {
|
|
32
54
|
return `₹${amount.toLocaleString("en-IN", { maximumFractionDigits: 2 })}`;
|
|
33
55
|
}
|
|
@@ -68,12 +90,90 @@ export async function collectDailyStatusSummary() {
|
|
|
68
90
|
.where(PRODUCT_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED)
|
|
69
91
|
.count()
|
|
70
92
|
.get();
|
|
93
|
+
const safeCount = async (q) => {
|
|
94
|
+
try {
|
|
95
|
+
const snap = await q.count().get();
|
|
96
|
+
return snap.data().count;
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
void normalizeError(err);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
/*
|
|
104
|
+
* audit-field-name-ok: these six collections — contactSubmissions,
|
|
105
|
+
* supportTickets, scammerProfiles, newsletterSubscribers, adminAuditLog and
|
|
106
|
+
* adminNotifications — have no shared field-name constant covering
|
|
107
|
+
* `createdAt`, and borrowing ORDER_FIELDS.CREATED_AT or PRODUCT_FIELDS.STATUS
|
|
108
|
+
* here would be actively misleading: it would assert a relationship to the
|
|
109
|
+
* orders/products schemas that does not exist, so a rename of ORDER_FIELDS
|
|
110
|
+
* would appear to affect a query it has nothing to do with. `createdAt` and
|
|
111
|
+
* `isRead` are BaseDocument fields common to every collection, not
|
|
112
|
+
* per-feature names. SUPPORT_TICKET_FIELDS is used below where it genuinely
|
|
113
|
+
* applies.
|
|
114
|
+
*/
|
|
115
|
+
const [contactSnap, contactTotal, newTicketCount, ticketsOpen, ticketsInProgress, ticketsWaitingOnUser, newScamReportCount, manualPaymentPendingCount, newSubscriberCount, adminActionCount, unreadAdminNotificationCount,] = await Promise.all([
|
|
116
|
+
db
|
|
117
|
+
.collection(CONTACT_SUBMISSIONS_COLLECTION)
|
|
118
|
+
.where(CREATED_AT, ">=", since)
|
|
119
|
+
.orderBy(CREATED_AT, "desc")
|
|
120
|
+
.limit(CONTACT_PREVIEW_LIMIT)
|
|
121
|
+
.get()
|
|
122
|
+
.catch((err) => {
|
|
123
|
+
void normalizeError(err);
|
|
124
|
+
return null;
|
|
125
|
+
}),
|
|
126
|
+
safeCount(db.collection(CONTACT_SUBMISSIONS_COLLECTION).where(CREATED_AT, ">=", since)),
|
|
127
|
+
safeCount(db.collection(SUPPORT_TICKET_COLLECTION).where(SUPPORT_TICKET_FIELDS.CREATED_AT, ">=", since)),
|
|
128
|
+
safeCount(db.collection(SUPPORT_TICKET_COLLECTION).where(SUPPORT_TICKET_FIELDS.STATUS, "==", TicketStatusValues.OPEN)),
|
|
129
|
+
safeCount(db.collection(SUPPORT_TICKET_COLLECTION).where(SUPPORT_TICKET_FIELDS.STATUS, "==", TicketStatusValues.IN_PROGRESS)),
|
|
130
|
+
safeCount(db.collection(SUPPORT_TICKET_COLLECTION).where(SUPPORT_TICKET_FIELDS.STATUS, "==", TicketStatusValues.WAITING_ON_USER)),
|
|
131
|
+
safeCount(db.collection(SCAMMER_COLLECTION).where(SCAMMER_FIELDS.CREATED_AT, ">=", since)),
|
|
132
|
+
safeCount(db
|
|
133
|
+
.collection(ORDER_COLLECTION)
|
|
134
|
+
.where(ORDER_FIELDS.STATUS, "==", OrderStatusValues.PENDING)
|
|
135
|
+
.where(ORDER_FIELDS.PAYMENT_STATUS, "==", "pending")),
|
|
136
|
+
safeCount(db.collection(NEWSLETTER_SUBSCRIBERS_COLLECTION).where(CREATED_AT, ">=", since)),
|
|
137
|
+
safeCount(db.collection(ADMIN_AUDIT_LOG_COLLECTION).where(CREATED_AT, ">=", since)),
|
|
138
|
+
safeCount(db.collection(ADMIN_NOTIFICATIONS_COLLECTION).where(IS_READ, "==", false)),
|
|
139
|
+
]);
|
|
140
|
+
/*
|
|
141
|
+
* `contactSubmissions` carries NO PII encryption — name, email, subject and
|
|
142
|
+
* message are stored in plaintext (see `ContactSubmissionDocument`). That is
|
|
143
|
+
* what lets the digest render a real `mailto:` reply link.
|
|
144
|
+
*
|
|
145
|
+
* Newsletter subscribers are the opposite: `email` is encrypted with an HMAC
|
|
146
|
+
* blind index, so that section is a COUNT and must stay one. Rendering
|
|
147
|
+
* addresses there would mean decrypting PII into an email body.
|
|
148
|
+
*/
|
|
149
|
+
const contactMessages = (contactSnap?.docs ?? []).map((d) => {
|
|
150
|
+
const data = d.data();
|
|
151
|
+
const message = data.message ?? "";
|
|
152
|
+
return {
|
|
153
|
+
id: d.id,
|
|
154
|
+
name: data.name ?? "Unknown",
|
|
155
|
+
email: data.email ?? "",
|
|
156
|
+
subject: data.subject ?? "(no subject)",
|
|
157
|
+
excerpt: message.length > 200 ? `${message.slice(0, 200)}…` : message,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
71
160
|
return {
|
|
72
161
|
orderCount: recentSnap.size,
|
|
73
162
|
revenue,
|
|
74
163
|
statusBreakdown,
|
|
75
164
|
stuckPendingCount: stuckSnap.data().count,
|
|
76
165
|
activeListingCount: activeSnap.data().count,
|
|
166
|
+
contactMessages,
|
|
167
|
+
contactTotal,
|
|
168
|
+
newTicketCount,
|
|
169
|
+
ticketsOpen,
|
|
170
|
+
ticketsInProgress,
|
|
171
|
+
ticketsWaitingOnUser,
|
|
172
|
+
newScamReportCount,
|
|
173
|
+
manualPaymentPendingCount,
|
|
174
|
+
newSubscriberCount,
|
|
175
|
+
adminActionCount,
|
|
176
|
+
unreadAdminNotificationCount,
|
|
77
177
|
};
|
|
78
178
|
}
|
|
79
179
|
function renderDigestHtml(summary, siteName, version) {
|
|
@@ -81,7 +181,37 @@ function renderDigestHtml(summary, siteName, version) {
|
|
|
81
181
|
const title = siteName ? `${siteName} — Daily Status` : "Daily Status";
|
|
82
182
|
return `<!DOCTYPE html>${renderToStaticMarkup(_jsx(EmailDoc, { title: title, children: _jsxs(EmailContainer, { children: [_jsx(EmailHeader, { brandName: siteName }), _jsxs(EmailRow, { children: [version
|
|
83
183
|
? `Deployment ${version} is live. Activity for the last 24 hours.`
|
|
84
|
-
: "Activity for the last 24 hours.", _jsx("br", {}), _jsx("br", {}), _jsx(StatLine, { label: "Orders placed", value: summary.orderCount }), _jsx(StatLine, { label: "Revenue (excl. cancelled/refunded)", value: currency(summary.revenue) }), _jsx(StatLine, { label: "Active listings", value: summary.activeListingCount }), _jsx(StatLine, { label: "Pending > 24h (needs attention)", value: summary.stuckPendingCount })] }), _jsx(EmailDivider, {}), _jsxs(EmailRow, { children: [_jsx(EmailBold, { children: "Orders by status" }), _jsx("br", {}), _jsx("br", {}), statusRows.length === 0 ? ("No orders in this window.") : (statusRows.map(([status, count]) => (_jsx(StatLine, { label: status, value: count }, status))))] }), _jsx(
|
|
184
|
+
: "Activity for the last 24 hours.", _jsx("br", {}), _jsx("br", {}), _jsx(StatLine, { label: "Orders placed", value: summary.orderCount }), _jsx(StatLine, { label: "Revenue (excl. cancelled/refunded)", value: currency(summary.revenue) }), _jsx(StatLine, { label: "Active listings", value: summary.activeListingCount }), _jsx(StatLine, { label: "Pending > 24h (needs attention)", value: summary.stuckPendingCount })] }), _jsx(EmailDivider, {}), _jsxs(EmailRow, { children: [_jsx(EmailBold, { children: "Orders by status" }), _jsx("br", {}), _jsx("br", {}), statusRows.length === 0 ? ("No orders in this window.") : (statusRows.map(([status, count]) => (_jsx(StatLine, { label: status, value: count }, status))))] }), _jsx(EmailDivider, {}), _jsxs(EmailRow, { children: [_jsx(EmailBold, { children: "Inbox" }), _jsx("br", {}), _jsx("br", {}), _jsx(StatLine, { label: "New contact messages", value: summary.contactTotal }), _jsx(StatLine, { label: "New support tickets", value: summary.newTicketCount }), _jsx(StatLine, { label: "Tickets open / in progress / waiting on user", value: `${summary.ticketsOpen} / ${summary.ticketsInProgress} / ${summary.ticketsWaitingOnUser}` }), _jsx(StatLine, { label: "Unread admin notifications", value: summary.unreadAdminNotificationCount })] }), summary.contactMessages.length > 0 ? (_jsxs(EmailRow, { children: [_jsx(EmailBold, { children: "Contact messages" }), _jsx("br", {}), _jsx("br", {}), summary.contactMessages.map((m) => (_jsxs(Fragment, { children: [_jsx(EmailBold, { children: m.subject }), _jsx("br", {}), m.name, " \u2014", " ", _jsxs(EmailLink, { href: `mailto:${m.email}?subject=${encodeURIComponent(`Re: ${m.subject}`)}`, children: ["Reply to ", m.email] }), _jsx("br", {}), m.excerpt, _jsx("br", {}), _jsx("br", {})] }, m.id))), summary.contactTotal > summary.contactMessages.length
|
|
185
|
+
? `…and ${summary.contactTotal - summary.contactMessages.length} more in /admin/contact.`
|
|
186
|
+
: null] })) : null, _jsx(EmailDivider, {}), _jsxs(EmailRow, { children: [_jsx(EmailBold, { children: "Needs attention" }), _jsx("br", {}), _jsx("br", {}), _jsx(StatLine, { label: "Manual payments pending", value: summary.manualPaymentPendingCount }), _jsx(StatLine, { label: "New scam reports", value: summary.newScamReportCount }), _jsx(StatLine, { label: "New newsletter subscribers", value: summary.newSubscriberCount }), _jsx(StatLine, { label: "Admin actions logged", value: summary.adminActionCount })] }), _jsx(EmailFooter, { copyright: `© ${new Date().getFullYear()}${siteName ? ` ${siteName}` : ""}. All rights reserved.` })] }) }))}`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The plain-text twin of `renderDigestHtml`.
|
|
190
|
+
*
|
|
191
|
+
* 🛑 This exists as a real function, beside the renderer, on purpose. The text
|
|
192
|
+
* part used to be a string concatenated inline at the call site, which meant
|
|
193
|
+
* every new HTML section silently did NOT appear in the text alternative —
|
|
194
|
+
* and a mail client showing the text part would render a digest missing
|
|
195
|
+
* exactly the sections someone had just added. Adding a section now means
|
|
196
|
+
* editing two adjacent functions rather than one function and one distant
|
|
197
|
+
* template literal.
|
|
198
|
+
*/
|
|
199
|
+
function renderDigestText(summary, version) {
|
|
200
|
+
const lines = [];
|
|
201
|
+
if (version)
|
|
202
|
+
lines.push(`Deployment ${version} is live.`, "");
|
|
203
|
+
lines.push(`Orders placed: ${summary.orderCount}`, `Revenue: ${currency(summary.revenue)}`, `Active listings: ${summary.activeListingCount}`, `Pending > 24h: ${summary.stuckPendingCount}`, "", "INBOX", `New contact messages: ${summary.contactTotal}`, `New support tickets: ${summary.newTicketCount}`, `Tickets open/in-progress/waiting: ${summary.ticketsOpen}/${summary.ticketsInProgress}/${summary.ticketsWaitingOnUser}`, `Unread admin notifications: ${summary.unreadAdminNotificationCount}`);
|
|
204
|
+
if (summary.contactMessages.length > 0) {
|
|
205
|
+
lines.push("", "CONTACT MESSAGES");
|
|
206
|
+
for (const m of summary.contactMessages) {
|
|
207
|
+
lines.push(`- ${m.subject} — ${m.name} <${m.email}>`, ` ${m.excerpt}`);
|
|
208
|
+
}
|
|
209
|
+
if (summary.contactTotal > summary.contactMessages.length) {
|
|
210
|
+
lines.push(` …and ${summary.contactTotal - summary.contactMessages.length} more in /admin/contact.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
lines.push("", "NEEDS ATTENTION", `Manual payments pending: ${summary.manualPaymentPendingCount}`, `New scam reports: ${summary.newScamReportCount}`, `New newsletter subscribers: ${summary.newSubscriberCount}`, `Admin actions logged: ${summary.adminActionCount}`);
|
|
214
|
+
return lines.join("\n");
|
|
85
215
|
}
|
|
86
216
|
const DEPLOY_MARKER_COLLECTION = "system";
|
|
87
217
|
const DEPLOY_MARKER_DOC = "deployDigest";
|
|
@@ -134,23 +264,36 @@ export async function runDailyStatusDigest(ctx, version) {
|
|
|
134
264
|
ctx.logger.warn("dailyStatusDigest: enabled but no recipients configured — skipping");
|
|
135
265
|
return { sent: false, reason: "no_recipients" };
|
|
136
266
|
}
|
|
137
|
-
|
|
267
|
+
/*
|
|
268
|
+
* 🛑 No CC, deliberately, and `ccRecipients` is no longer read here.
|
|
269
|
+
*
|
|
270
|
+
* Resend bills a CC as a separate delivery, so a three-recipient TO plus one
|
|
271
|
+
* CC was four of the day's 100 emails for one report. The digest now goes to
|
|
272
|
+
* a single address. `emailSettings.dailyDigest.ccRecipients` remains on the
|
|
273
|
+
* schema only so existing documents do not fail validation — nothing reads
|
|
274
|
+
* it, and the admin UI no longer offers it.
|
|
275
|
+
*/
|
|
138
276
|
const summary = await collectDailyStatusSummary();
|
|
139
277
|
// No hardcoded brand fallback — appkit is consumer-agnostic; the site name
|
|
140
278
|
// comes from settings or the consumer's own env var.
|
|
141
279
|
const siteName = settings?.siteName?.trim() || process.env.NEXT_PUBLIC_SITE_NAME?.trim() || "";
|
|
142
280
|
const { error } = await sendEmail({
|
|
143
281
|
to: recipients,
|
|
144
|
-
// Spread rather than `cc: undefined` — SendEmailOptions has a
|
|
145
|
-
// Record<string, JsonValue> index signature that rejects undefined.
|
|
146
|
-
...(ccRecipients.length > 0 ? { cc: ccRecipients } : {}),
|
|
147
282
|
subject: "Daily Status",
|
|
148
283
|
html: renderDigestHtml(summary, siteName, version),
|
|
149
|
-
text: (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
284
|
+
text: renderDigestText(summary, version),
|
|
285
|
+
}, {
|
|
286
|
+
channel: "email",
|
|
287
|
+
feature: "daily_digest",
|
|
288
|
+
/*
|
|
289
|
+
* STAFF, and this is the single most load-bearing `audience` in the
|
|
290
|
+
* codebase. The kill switch suppresses user-facing mail only, so turning
|
|
291
|
+
* email off must never blind the operator — this digest is now the only
|
|
292
|
+
* way a contact message, a new support ticket or a stuck payment proof
|
|
293
|
+
* gets noticed at all. Marking it `user` would mean switching off
|
|
294
|
+
* marketing also switched off the ability to notice an outage.
|
|
295
|
+
*/
|
|
296
|
+
audience: "staff",
|
|
154
297
|
});
|
|
155
298
|
if (error) {
|
|
156
299
|
ctx.logger.error("dailyStatusDigest: send failed", { error: String(error) });
|
|
@@ -47,15 +47,27 @@ export async function runHardBanReinstatement(ctx) {
|
|
|
47
47
|
ctx.logger.error("Failed to reinstate expired hard ban", err, { uid: user.uid });
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
// Sequential: `account_action` is email-eligible — deliberately so, since a
|
|
51
|
+
// ban or unban has to reach someone outside the app they just lost or
|
|
52
|
+
// regained. Each send reserves a unit from the single-document daily budget
|
|
53
|
+
// counter, so a concurrent sweep would contend. See pendingOrderTimeout.
|
|
54
|
+
for (const user of expired) {
|
|
55
|
+
try {
|
|
56
|
+
await sendNotification({
|
|
57
|
+
userId: user.uid,
|
|
58
|
+
type: "account_action",
|
|
59
|
+
priority: "normal",
|
|
60
|
+
title: "Account access restored",
|
|
61
|
+
message: "Your account access has been restored. If your seller store was suspended, contact support to reactivate it.",
|
|
62
|
+
relatedId: user.uid,
|
|
63
|
+
relatedType: "user",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
void normalizeError(err);
|
|
68
|
+
ctx.logger.error("Failed to notify user of ban reinstatement (non-fatal)", err, { uid: user.uid });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
59
71
|
ctx.logger.info("Hard ban reinstatement sweep complete", {
|
|
60
72
|
scanned: expired.length,
|
|
61
73
|
reinstated,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { normalizeError } from "../../../../errors/normalize";
|
|
2
|
-
import { storeRepository, userRepository } from "../../../../repositories";
|
|
2
|
+
import { adminNotificationsRepository, storeRepository, userRepository } from "../../../../repositories";
|
|
3
3
|
import { decryptPii } from "../../../../security/index";
|
|
4
4
|
import { sendWhatsAppBusinessMessage, buildPurchaseAnnouncementMessage, } from "../../../../features/whatsapp-bot/server";
|
|
5
5
|
import { resolveKeys } from "../../../../core/integration-keys";
|
|
6
|
+
import { guardSend, logSuppressedSend } from "../../notifications/send-guard";
|
|
6
7
|
async function sendAnnouncement(ctx, toPhone, message, phoneNumberId, accessToken, label, orderId) {
|
|
7
8
|
try {
|
|
8
9
|
const sent = await sendWhatsAppBusinessMessage({
|
|
@@ -68,21 +69,10 @@ async function resolveWhatsAppCredentials(ctx, orderId) {
|
|
|
68
69
|
return {
|
|
69
70
|
phoneNumberId: usable(db.whatsappPhoneNumberId) || usable(ctx.env("WHATSAPP_PHONE_NUMBER_ID")),
|
|
70
71
|
accessToken: usable(db.whatsappCloudApiToken) || usable(ctx.env("WHATSAPP_CLOUD_API_TOKEN")),
|
|
71
|
-
adminNumbersRaw: usable(db.whatsappAdminNotifyNumbers) || usable(ctx.env("WHATSAPP_ADMIN_NOTIFY_NUMBERS")),
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
export async function handleOrderCreate(input, ctx) {
|
|
75
75
|
const { orderId, order } = input;
|
|
76
|
-
// Credentials resolve DB-first (Admin → Site Settings → WhatsApp) with an env
|
|
77
|
-
// fallback, matching resolveKeys() and every other WhatsApp send path. This
|
|
78
|
-
// read ctx.env() ONLY until 2026-08-22, so credentials saved in Site Settings
|
|
79
|
-
// never reached the order-placed announcement — it silently no-op'd unless the
|
|
80
|
-
// Functions runtime also carried the env vars.
|
|
81
|
-
const { phoneNumberId, accessToken, adminNumbersRaw } = await resolveWhatsAppCredentials(ctx, orderId);
|
|
82
|
-
if (!phoneNumberId || !accessToken) {
|
|
83
|
-
ctx.logger.info("WhatsApp Cloud API not configured — skipping announcement", { orderId });
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
76
|
const items = order.items ?? [];
|
|
87
77
|
const firstItem = items[0];
|
|
88
78
|
const firstItemName = firstItem?.productTitle ?? "an item";
|
|
@@ -95,20 +85,67 @@ export async function handleOrderCreate(input, ctx) {
|
|
|
95
85
|
totalAmount: order.totalPrice ?? 0,
|
|
96
86
|
orderId,
|
|
97
87
|
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
88
|
+
/*
|
|
89
|
+
* Staff side: one durable row, not a WhatsApp message per admin number.
|
|
90
|
+
*
|
|
91
|
+
* 🛑 This used to loop `whatsappAdminNotifyNumbers` and send the purchase
|
|
92
|
+
* announcement to every one of them, ON EVERY ORDER — the highest-volume
|
|
93
|
+
* staff blast in the codebase. It is now an `adminNotifications` row that
|
|
94
|
+
* the admin inbox reads and the daily digest counts.
|
|
95
|
+
*
|
|
96
|
+
* Written BEFORE the credential check on purpose. The old code returned
|
|
97
|
+
* early when WhatsApp was unconfigured, which meant that with no Meta
|
|
98
|
+
* credentials — the state this project is actually in — an order produced
|
|
99
|
+
* no staff signal at all, and no record that it hadn't.
|
|
100
|
+
*/
|
|
101
|
+
try {
|
|
102
|
+
await adminNotificationsRepository.create({
|
|
103
|
+
category: "growth",
|
|
104
|
+
title: "New order placed",
|
|
105
|
+
body: message,
|
|
106
|
+
severity: "info",
|
|
107
|
+
isRead: false,
|
|
108
|
+
entityType: "order",
|
|
109
|
+
entityId: orderId,
|
|
110
|
+
audienceUserIds: [],
|
|
111
|
+
});
|
|
104
112
|
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
void normalizeError(err);
|
|
115
|
+
ctx.logger.error("Failed to write admin order notification (non-fatal)", err, { orderId });
|
|
116
|
+
}
|
|
117
|
+
/*
|
|
118
|
+
* Seller side keeps WhatsApp: it is ONE message to ONE person about their
|
|
119
|
+
* own sale, which is the case the channel is good at. Credentials resolve
|
|
120
|
+
* DB-first (Admin → Site Settings → WhatsApp) with an env fallback — this
|
|
121
|
+
* read `ctx.env()` only until 2026-08-22, so credentials saved in Site
|
|
122
|
+
* Settings never reached the announcement and it silently no-op'd.
|
|
123
|
+
*/
|
|
105
124
|
const storeId = order.storeId;
|
|
106
|
-
if (storeId) {
|
|
107
|
-
|
|
125
|
+
if (!storeId) {
|
|
126
|
+
ctx.logger.info("Order has no storeId — nothing to announce to a seller", { orderId });
|
|
127
|
+
return;
|
|
108
128
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
129
|
+
const { phoneNumberId, accessToken } = await resolveWhatsAppCredentials(ctx, orderId);
|
|
130
|
+
if (!phoneNumberId || !accessToken) {
|
|
131
|
+
ctx.logger.info("WhatsApp Cloud API not configured — skipping seller announcement", { orderId });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Audience is "user": a seller receiving news of their own sale is a
|
|
135
|
+
// marketplace participant, not an operator of the site. Staff exemption is
|
|
136
|
+
// for the digest and the payout summary, and widening it to "anyone we think
|
|
137
|
+
// ought to see this" would make the kill switch mean nothing.
|
|
138
|
+
const guard = {
|
|
139
|
+
channel: "whatsapp",
|
|
140
|
+
feature: "order_placed_seller",
|
|
141
|
+
audience: "user",
|
|
142
|
+
};
|
|
143
|
+
const decision = await guardSend(guard);
|
|
144
|
+
if (!decision.allow) {
|
|
145
|
+
logSuppressedSend(guard, decision);
|
|
146
|
+
ctx.logger.info("Seller order announcement suppressed by messaging guard", { orderId, storeId });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
await notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId);
|
|
150
|
+
ctx.logger.info(`Order announcement complete`, { orderId, storeId });
|
|
114
151
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeError } from "../../../../errors/normalize";
|
|
2
|
-
import { userRepository } from "../../../../repositories";
|
|
2
|
+
import { adminNotificationsRepository, userRepository } from "../../../../repositories";
|
|
3
3
|
import { sendNotification } from "../../../../features/admin/actions/notification-actions";
|
|
4
4
|
import { SCAM_TYPE_LABELS } from "../../../../features/scams/constants/scam-types";
|
|
5
5
|
export async function handleScamReportCreate(input, ctx) {
|
|
@@ -12,7 +12,7 @@ export async function handleScamReportCreate(input, ctx) {
|
|
|
12
12
|
const reporter = await userRepository.findById(reportedBy);
|
|
13
13
|
await sendNotification({
|
|
14
14
|
userId: reportedBy,
|
|
15
|
-
type: "
|
|
15
|
+
type: "scam_report_update",
|
|
16
16
|
priority: "normal",
|
|
17
17
|
title: "Scam report submitted",
|
|
18
18
|
message: `Your report for "${name}" has been received. Our team will review it within 48 hours.`,
|
|
@@ -27,31 +27,42 @@ export async function handleScamReportCreate(input, ctx) {
|
|
|
27
27
|
ctx.logger.error("Failed to notify reporter (non-fatal)", err, { scammerId, reportedBy });
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
/*
|
|
31
|
+
* 2. Tell STAFF — once, into the admin inbox.
|
|
32
|
+
*
|
|
33
|
+
* 🛑 This used to be `Promise.all` over `userRepository.list({ pageSize: 100 })`,
|
|
34
|
+
* i.e. **one `sendNotification` per employee, up to 100, per report filed**.
|
|
35
|
+
* Each of those was a potential email against a 100/day Resend allowance, so
|
|
36
|
+
* a single scam report could consume the entire day — and it was a hundred
|
|
37
|
+
* concurrent Firestore writes besides.
|
|
38
|
+
*
|
|
39
|
+
* It was also the wrong shape regardless of cost: a scam report is one
|
|
40
|
+
* event needing one person to action it, not N personal messages. The
|
|
41
|
+
* `adminNotifications` collection exists for exactly this and is already
|
|
42
|
+
* read by `GET /api/admin/admin-notifications` and surfaced in the admin
|
|
43
|
+
* inbox — `audienceUserIds: []` means "all admins" rather than a fan-out.
|
|
44
|
+
*
|
|
45
|
+
* The daily digest carries the count as well, so nothing depends on someone
|
|
46
|
+
* happening to look at the inbox.
|
|
47
|
+
*/
|
|
31
48
|
try {
|
|
32
|
-
const result = await userRepository.list({
|
|
33
|
-
filters: "role==employee",
|
|
34
|
-
page: 1,
|
|
35
|
-
pageSize: 100,
|
|
36
|
-
});
|
|
37
49
|
const scamTypeLabel = scamType ? (SCAM_TYPE_LABELS[scamType] ?? scamType) : "Unknown";
|
|
38
50
|
const amountStr = amountLost ? ` ₹${amountLost.toLocaleString("en-IN")}` : "";
|
|
39
51
|
const platformStr = scamPlatform ? ` via ${scamPlatform}` : "";
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
type: "account_action",
|
|
43
|
-
priority: "normal",
|
|
52
|
+
await adminNotificationsRepository.create({
|
|
53
|
+
category: "fraud",
|
|
44
54
|
title: "New scam report submitted",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
body: `A report was submitted for "${name}" — ${scamTypeLabel}${platformStr}.${amountStr}`,
|
|
56
|
+
severity: "warning",
|
|
57
|
+
isRead: false,
|
|
58
|
+
entityType: "scammer",
|
|
59
|
+
entityId: scammerId,
|
|
60
|
+
audienceUserIds: [],
|
|
61
|
+
});
|
|
51
62
|
}
|
|
52
63
|
catch (err) {
|
|
53
64
|
void normalizeError(err);
|
|
54
|
-
ctx.logger.error("Failed to
|
|
65
|
+
ctx.logger.error("Failed to write admin scam-report notification (non-fatal)", err, { scammerId });
|
|
55
66
|
}
|
|
56
67
|
ctx.logger.info("onScamReportCreate complete", { scammerId, reportedBy: reportedBy ?? null });
|
|
57
68
|
}
|
|
@@ -15,7 +15,7 @@ export async function handleScamReportRejected(input, ctx) {
|
|
|
15
15
|
const reporter = await userRepository.findById(reportedBy);
|
|
16
16
|
await sendNotification({
|
|
17
17
|
userId: reportedBy,
|
|
18
|
-
type: "
|
|
18
|
+
type: "scam_report_update",
|
|
19
19
|
priority: "normal",
|
|
20
20
|
title: "Scam report not verified",
|
|
21
21
|
message: `Your report for "${name}" could not be verified with the evidence provided. You may submit a new report with additional evidence.`,
|
|
@@ -15,7 +15,7 @@ export async function handleScamReportVerified(input, ctx) {
|
|
|
15
15
|
const reporter = await userRepository.findById(reportedBy);
|
|
16
16
|
await sendNotification({
|
|
17
17
|
userId: reportedBy,
|
|
18
|
-
type: "
|
|
18
|
+
type: "scam_report_update",
|
|
19
19
|
priority: "normal",
|
|
20
20
|
title: "Your scam report was verified",
|
|
21
21
|
message: `The report for "${name}" has been verified and published to the Scam Registry. Thank you for helping protect the community.`,
|
|
@@ -10,7 +10,7 @@ export async function handleSupportTicketCreate(input, ctx) {
|
|
|
10
10
|
try {
|
|
11
11
|
await sendNotification({
|
|
12
12
|
userId,
|
|
13
|
-
type: "
|
|
13
|
+
type: "support_ticket_update",
|
|
14
14
|
priority: "normal",
|
|
15
15
|
title: "Support ticket received",
|
|
16
16
|
message: `We received your support request: "${ticket.subject ?? "your ticket"}". We'll get back to you soon.`,
|
|
@@ -76,15 +76,27 @@ export async function runPaymentReviewAutoApprove(ctx) {
|
|
|
76
76
|
ctx.logger.error("Failed to auto-approve payment proof", err, { orderId: entry.id });
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
// Sequential: `payment_review` is email-eligible, so each send reserves a
|
|
80
|
+
// unit from the single-document daily budget counter. See pendingOrderTimeout.
|
|
81
|
+
for (const entry of unreviewed) {
|
|
82
|
+
try {
|
|
83
|
+
await sendNotification({
|
|
84
|
+
userId: entry.data.userId,
|
|
85
|
+
type: "payment_review",
|
|
86
|
+
priority: "normal",
|
|
87
|
+
title: ORDER_MESSAGES.AUTO_APPROVED_TITLE,
|
|
88
|
+
message: ORDER_MESSAGES.AUTO_APPROVED_MESSAGE(entry.data.productTitle),
|
|
89
|
+
relatedId: entry.id,
|
|
90
|
+
relatedType: "order",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
void normalizeError(err);
|
|
95
|
+
ctx.logger.error("Failed to notify buyer of auto-approved payment (non-fatal)", err, {
|
|
96
|
+
orderId: entry.id,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
88
100
|
ctx.logger.info("Payment review auto-approve sweep complete", {
|
|
89
101
|
scanned: unreviewed.length,
|
|
90
102
|
approved,
|
|
@@ -43,16 +43,29 @@ export async function runPaymentWindowTimeout(ctx) {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
// Sequential: `order_cancelled` is email-eligible, so each send reserves a
|
|
47
|
+
// unit from the single-document daily budget counter and a concurrent sweep
|
|
48
|
+
// would contend on it. See pendingOrderTimeout for the full reasoning.
|
|
49
|
+
for (const entry of expired) {
|
|
50
|
+
try {
|
|
51
|
+
await sendNotification({
|
|
52
|
+
userId: entry.data.userId,
|
|
53
|
+
type: "order_cancelled",
|
|
54
|
+
priority: "normal",
|
|
55
|
+
title: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_TITLE,
|
|
56
|
+
message: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_MESSAGE(entry.data.productTitle),
|
|
57
|
+
relatedId: entry.id,
|
|
58
|
+
relatedType: "order",
|
|
59
|
+
orderWhatsappAddonPaid: entry.data.whatsappNotifyAddon === true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
void normalizeError(err);
|
|
64
|
+
ctx.logger.error("Failed to notify buyer of expired payment window (non-fatal)", err, {
|
|
65
|
+
orderId: entry.id,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
56
69
|
ctx.logger.info("Payment window timeout sweep complete", {
|
|
57
70
|
scanned: expired.length,
|
|
58
71
|
restored,
|
|
@@ -41,15 +41,41 @@ export async function runPendingOrderTimeout(ctx) {
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
/*
|
|
45
|
+
* Sequential, not `Promise.allSettled`.
|
|
46
|
+
*
|
|
47
|
+
* 🛑 `order_cancelled` is email-ELIGIBLE, so each of these reserves a unit
|
|
48
|
+
* from the daily budget — a single Firestore document. Firestore sustains
|
|
49
|
+
* roughly one write per second on one document, so a concurrent sweep would
|
|
50
|
+
* contend on it, retry, and slow the job down more than the serial version
|
|
51
|
+
* it replaced. Awaiting in a loop is the cheap fix; the alternative is
|
|
52
|
+
* sharding the counter to defend a fan-out nobody needs.
|
|
53
|
+
*
|
|
54
|
+
* Safe on latency: this runs in a Firebase Function with a 300s budget, and
|
|
55
|
+
* the set is bounded by however many orders timed out in one window.
|
|
56
|
+
*
|
|
57
|
+
* Failures stay non-fatal per order — a notification that will not send must
|
|
58
|
+
* not stop the remaining cancellations being announced.
|
|
59
|
+
*/
|
|
60
|
+
for (const entry of timedOut) {
|
|
61
|
+
try {
|
|
62
|
+
await sendNotification({
|
|
63
|
+
userId: entry.data.userId,
|
|
64
|
+
type: "order_cancelled",
|
|
65
|
+
priority: "normal",
|
|
66
|
+
title: ORDER_MESSAGES.CANCELLED_TITLE,
|
|
67
|
+
message: ORDER_MESSAGES.CANCELLED_TIMEOUT_MESSAGE(entry.data.productTitle, timeoutHours),
|
|
68
|
+
relatedId: entry.id,
|
|
69
|
+
relatedType: "order",
|
|
70
|
+
orderWhatsappAddonPaid: entry.data.whatsappNotifyAddon === true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
void normalizeError(err);
|
|
75
|
+
ctx.logger.error("Failed to notify buyer of timed-out order (non-fatal)", err, {
|
|
76
|
+
orderId: entry.id,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
54
80
|
ctx.logger.info("Pending order timeout complete", { cancelled: timedOut.length, restored });
|
|
55
81
|
}
|