@mohasinac/appkit 4.35.1 → 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/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/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
|
@@ -12,7 +12,7 @@ import { roundRupees } from "../../../../utils/number.formatter";
|
|
|
12
12
|
import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../../../errors";
|
|
13
13
|
import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
14
14
|
import { serverLogger } from "../../../../monitoring";
|
|
15
|
-
import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
|
|
15
|
+
import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
|
|
16
16
|
import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math";
|
|
17
17
|
import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
|
|
18
18
|
import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
|
|
@@ -1099,28 +1099,34 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
|
|
|
1099
1099
|
void normalizeError(updErr);
|
|
1100
1100
|
serverLogger.error("verifyAndPlaceRazorpayOrderAction: failed to flag refundPending after auto-refund failure", { orderId: primaryOrderId, err: updErr instanceof Error ? updErr.message : String(updErr) });
|
|
1101
1101
|
});
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1102
|
+
/*
|
|
1103
|
+
* One admin-inbox row, not one notification per admin.
|
|
1104
|
+
*
|
|
1105
|
+
* 🛑 The comment that used to sit here said this "mirrors
|
|
1106
|
+
* onScamReportCreate's employee notification pattern" — and it did, which
|
|
1107
|
+
* is exactly the problem: that pattern was a 100-way concurrent fan-out of
|
|
1108
|
+
* an email-eligible type, and it has been removed. A failed auto-refund is
|
|
1109
|
+
* ONE event needing ONE person to act, not N personal messages.
|
|
1110
|
+
*
|
|
1111
|
+
* It also no longer queries the admin list at all, so the failure mode
|
|
1112
|
+
* where "we couldn't look up the admins" silently swallowed the alert is
|
|
1113
|
+
* gone with it.
|
|
1114
|
+
*/
|
|
1115
|
+
await adminNotificationsRepository
|
|
1116
|
+
.create({
|
|
1117
|
+
category: "payouts",
|
|
1118
|
+
title: "Automatic refund failed",
|
|
1119
|
+
body: `Automatic refund failed for order ${primaryOrderId} (${unavailablePaid.length} unavailable item(s)) — manual refund required.`,
|
|
1120
|
+
severity: "error",
|
|
1121
|
+
isRead: false,
|
|
1122
|
+
entityType: "order",
|
|
1123
|
+
entityId: primaryOrderId,
|
|
1124
|
+
audienceUserIds: [],
|
|
1125
|
+
})
|
|
1126
|
+
.catch((notifyErr) => {
|
|
1121
1127
|
void normalizeError(notifyErr);
|
|
1122
|
-
serverLogger.error("verifyAndPlaceRazorpayOrderAction: failed to
|
|
1123
|
-
}
|
|
1128
|
+
serverLogger.error("verifyAndPlaceRazorpayOrderAction: failed to write admin notification for failed auto-refund", { orderId: primaryOrderId });
|
|
1129
|
+
});
|
|
1124
1130
|
}
|
|
1125
1131
|
}
|
|
1126
1132
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
import { wrapAction } from "@mohasinac/appkit/server";
|
|
3
|
-
import { orderRepository, storeRepository
|
|
3
|
+
import { adminNotificationsRepository, orderRepository, storeRepository } from "../../../../repositories";
|
|
4
4
|
import { requireRoleUser } from "../../../../providers/auth-firebase/helpers";
|
|
5
5
|
import { createOrderSchema, updateOrderStatusSchema, cancelOrderSchema, returnRequestSchema, } from "../../../shared/features/orders/schema";
|
|
6
6
|
import { isFinalSaleExempt } from "../../../shared/features/orders/return-reasons";
|
|
@@ -115,51 +115,44 @@ function normalizeUpi(vpa) {
|
|
|
115
115
|
}
|
|
116
116
|
const REASON_REQUIRED_MSG = "A reason is required";
|
|
117
117
|
/**
|
|
118
|
-
* Fast-review
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
118
|
+
* Fast-review signal — a payment proof needs acting on inside the 2-hour
|
|
119
|
+
* auto-approve window, and the admin should not have to hunt the orders list
|
|
120
|
+
* to find it.
|
|
121
|
+
*
|
|
122
|
+
* 🛑 This used to `Promise.allSettled` a WhatsApp message to EVERY number in
|
|
123
|
+
* `whatsappAdminNotifyNumbers`, on every proof upload. Three problems, in
|
|
124
|
+
* increasing order of importance:
|
|
125
|
+
*
|
|
126
|
+
* 1. it was an unbounded fan-out on a metered channel;
|
|
127
|
+
* 2. WhatsApp free-form messages are rejected by Meta outside the 24-hour
|
|
128
|
+
* customer-service window, so the alert most likely to matter — the one
|
|
129
|
+
* arriving after a quiet period — is precisely the one that would fail;
|
|
130
|
+
* 3. it left no record. A missed push was simply gone, whereas the queue at
|
|
131
|
+
* `/admin/orders` is the thing an admin actually works from.
|
|
132
|
+
*
|
|
133
|
+
* One `adminNotifications` row instead: durable, deduplicated by nature, read
|
|
134
|
+
* by the admin inbox, and counted in the daily digest. Still fire-and-forget
|
|
135
|
+
* and still non-fatal — the buyer's upload already succeeded, and a failure to
|
|
136
|
+
* announce it must never surface as an upload error.
|
|
123
137
|
*/
|
|
124
138
|
function notifyAdminsOfPaymentProof(order) {
|
|
125
139
|
void (async () => {
|
|
126
140
|
try {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.
|
|
136
|
-
|
|
137
|
-
.filter(Boolean);
|
|
138
|
-
if (adminNumbers.length === 0)
|
|
139
|
-
return;
|
|
140
|
-
// No hardcoded domain fallback here (audit-ssr-in-appkit) — every
|
|
141
|
-
// consumer sets NEXT_PUBLIC_SITE_URL; skip the review link rather than
|
|
142
|
-
// guessing a brand-specific domain from inside appkit/_internal/.
|
|
143
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "";
|
|
144
|
-
const message = buildPaymentProofReviewMessage({
|
|
145
|
-
orderId: order.id,
|
|
146
|
-
buyerName: order.userName,
|
|
147
|
-
productTitle: order.productTitle,
|
|
148
|
-
totalAmount: order.totalPrice,
|
|
149
|
-
reviewUrl: `${baseUrl}/admin/orders`,
|
|
141
|
+
const { adminNotificationsRepository } = await import("../../../../repositories");
|
|
142
|
+
await adminNotificationsRepository.create({
|
|
143
|
+
category: "payouts",
|
|
144
|
+
title: "Payment proof awaiting review",
|
|
145
|
+
body: `${order.userName} uploaded proof for "${order.productTitle}" (₹${order.totalPrice.toLocaleString("en-IN")}). Review before the 2-hour auto-approve window closes.`,
|
|
146
|
+
severity: "warning",
|
|
147
|
+
isRead: false,
|
|
148
|
+
entityType: "order",
|
|
149
|
+
entityId: order.id,
|
|
150
|
+
audienceUserIds: [],
|
|
150
151
|
});
|
|
151
|
-
await Promise.allSettled(adminNumbers.map((toPhone) => sendWhatsAppBusinessMessage({
|
|
152
|
-
toPhone,
|
|
153
|
-
message,
|
|
154
|
-
phoneNumberId: keys.whatsappPhoneNumberId,
|
|
155
|
-
accessToken: keys.whatsappCloudApiToken,
|
|
156
|
-
})));
|
|
157
152
|
}
|
|
158
153
|
catch (err) {
|
|
159
154
|
void normalizeError(err);
|
|
160
|
-
|
|
161
|
-
// Cloud API hiccup here must never surface as a proof-upload failure.
|
|
162
|
-
serverLogger.warn("Payment-proof admin WhatsApp push failed (non-fatal)", {
|
|
155
|
+
serverLogger.warn("Payment-proof admin notification failed (non-fatal)", {
|
|
163
156
|
orderId: order.id,
|
|
164
157
|
error: err instanceof Error ? err.message : String(err),
|
|
165
158
|
});
|
|
@@ -384,17 +377,33 @@ export async function raiseOrderDisputeAction(orderId, reason) {
|
|
|
384
377
|
disputeReason: reason,
|
|
385
378
|
disputeStatus: "open",
|
|
386
379
|
});
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
380
|
+
/*
|
|
381
|
+
* One admin-inbox row, not one notification per admin.
|
|
382
|
+
*
|
|
383
|
+
* 🛑 This was `Promise.allSettled` over `userRepository.findByRole("admin")`
|
|
384
|
+
* sending `payment_review` — an email-ELIGIBLE type — so a single disputed
|
|
385
|
+
* order cost one email per admin, concurrently. Same defect as the
|
|
386
|
+
* scam-report employee blast (removed in the same change), and the comment
|
|
387
|
+
* on that one said it was copying this pattern.
|
|
388
|
+
*
|
|
389
|
+
* `adminNotifications` is durable, appears in the admin inbox, is counted
|
|
390
|
+
* in the daily digest, and costs exactly one write regardless of how many
|
|
391
|
+
* admins exist.
|
|
392
|
+
*/
|
|
393
|
+
await adminNotificationsRepository
|
|
394
|
+
.create({
|
|
395
|
+
category: "fraud",
|
|
394
396
|
title: "Dispute raised on auto-approved order",
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
body: `A dispute was raised on order "${order.productTitle}" (auto-approved payment). Reason: ${reason}.`,
|
|
398
|
+
severity: "error",
|
|
399
|
+
isRead: false,
|
|
400
|
+
entityType: "order",
|
|
401
|
+
entityId: orderId,
|
|
402
|
+
audienceUserIds: [],
|
|
403
|
+
})
|
|
404
|
+
.catch((err) => {
|
|
405
|
+
void normalizeError(err);
|
|
406
|
+
serverLogger.error("Failed to write dispute admin notification (non-fatal)", { orderId });
|
|
407
|
+
});
|
|
399
408
|
});
|
|
400
409
|
}
|
|
@@ -39,7 +39,7 @@ import type { PublicSiteSettings } from "../../../shared/features/site-settings/
|
|
|
39
39
|
*/
|
|
40
40
|
export declare const PUBLIC_SITE_SETTINGS_FIELDS: readonly ["contact", "payment", "listings", "notificationChannels", "announcementBar", "navConfig", "actionConfig", "background", "watermark"];
|
|
41
41
|
/** Everything else, with the reason it stays server-side. */
|
|
42
|
-
export declare const PRIVATE_SITE_SETTINGS_FIELDS: readonly ["id", "createdAt", "updatedAt", "credentials", "emailSettings", "legalPages", "commissions", "emi", "gst", "laborRate", "adSettings", "integrations", "platformLimits", "auctionConfig", "disabledRoutes", "siteName", "tagline", "motto", "logo", "favicon", "seo", "socialLinks", "features", "shipping", "returns", "faq", "aboutContent", "navbarConfig", "footerConfig", "theme", "featuredResults"];
|
|
42
|
+
export declare const PRIVATE_SITE_SETTINGS_FIELDS: readonly ["id", "createdAt", "updatedAt", "credentials", "emailSettings", "legalPages", "commissions", "emi", "gst", "laborRate", "adSettings", "integrations", "platformLimits", "auctionConfig", "disabledRoutes", "siteName", "tagline", "motto", "logo", "favicon", "seo", "socialLinks", "features", "shipping", "returns", "faq", "aboutContent", "navbarConfig", "footerConfig", "theme", "featuredResults", "messaging"];
|
|
43
43
|
export interface ToPublicSiteSettingsOptions {
|
|
44
44
|
/**
|
|
45
45
|
* The resolved marker → wordmark → text watermark chain. Injected because
|
|
@@ -47,6 +47,14 @@ export const PRIVATE_SITE_SETTINGS_FIELDS = [
|
|
|
47
47
|
"footerConfig", // rendered server-side
|
|
48
48
|
"theme", // delivered as an SSR prop to ThemeProvider by app/[locale]/layout.tsx
|
|
49
49
|
"featuredResults", // rendered server-side by search
|
|
50
|
+
// Kill switch + daily send ceiling. Read ONLY by guardSend, server-side. Two
|
|
51
|
+
// reasons it stays private beyond "no client reader": the ceiling and the
|
|
52
|
+
// headroom left in it are operational detail, and publishing "user email is
|
|
53
|
+
// currently off" tells an attacker that password-reset traffic is going
|
|
54
|
+
// unwatched. The one client that arguably wants it — the admin ticket-reply
|
|
55
|
+
// checkbox, which disables itself when email is off — is behind the admin
|
|
56
|
+
// API, which returns the full document.
|
|
57
|
+
"messaging",
|
|
50
58
|
];
|
|
51
59
|
export function toPublicSiteSettings(doc, opts) {
|
|
52
60
|
return {
|
|
@@ -112,6 +112,10 @@ async function settleAuction(ctx, product) {
|
|
|
112
112
|
relatedType: "product",
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
|
+
// audit-unguarded-send-ok: `bid_lost` is email-INELIGIBLE — these return at
|
|
116
|
+
// the eligibility gate having written only the bell row, so they never
|
|
117
|
+
// reach the budget counter. Same reasoning as the settled-auction fan-out
|
|
118
|
+
// below.
|
|
115
119
|
await Promise.allSettled(activeBids.slice(0, 50).map(({ data: bid }) => sendNotification({
|
|
116
120
|
userId: bid.userId,
|
|
117
121
|
type: "bid_lost",
|
|
@@ -181,6 +185,15 @@ async function settleAuction(ctx, product) {
|
|
|
181
185
|
actionUrl: AUCTION_CHECKOUT_URL,
|
|
182
186
|
actionLabel: AUCTION_MESSAGES.WON_ACTION_LABEL,
|
|
183
187
|
});
|
|
188
|
+
// audit-unguarded-send-ok: `bid_lost` is email-INELIGIBLE, so every one of
|
|
189
|
+
// these returns at the eligibility gate in sendNotification having written
|
|
190
|
+
// only the in-app bell row — it never reaches guardSend or the daily budget
|
|
191
|
+
// counter, so there is nothing here to contend on. This was THE fan-out that
|
|
192
|
+
// motivated the whole change (50 concurrent emails per settled auction, with
|
|
193
|
+
// every expired auction settling at once); it is now 50 concurrent Firestore
|
|
194
|
+
// writes to 50 DIFFERENT notification documents, which is what this loop
|
|
195
|
+
// always was underneath. If `bid_lost` is ever made eligible, this must
|
|
196
|
+
// become sequential and the counter must be sharded first.
|
|
184
197
|
await Promise.allSettled(loserEntries.slice(0, 50).map(({ data: bid }) => sendNotification({
|
|
185
198
|
userId: bid.userId,
|
|
186
199
|
type: "bid_lost",
|
|
@@ -13,12 +13,37 @@
|
|
|
13
13
|
* sync ceiling — it has the Function's much larger budget.
|
|
14
14
|
*/
|
|
15
15
|
import type { JobContext } from "../runtime/types";
|
|
16
|
+
/** One inbound contact message, rendered with a mailto: reply link. */
|
|
17
|
+
export interface DigestContactMessage {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
email: string;
|
|
21
|
+
subject: string;
|
|
22
|
+
/** First ~200 chars. The full text is in /admin/contact. */
|
|
23
|
+
excerpt: string;
|
|
24
|
+
}
|
|
16
25
|
export interface DailyStatusDigestSummary {
|
|
17
26
|
orderCount: number;
|
|
18
27
|
revenue: number;
|
|
19
28
|
statusBreakdown: Record<string, number>;
|
|
20
29
|
stuckPendingCount: number;
|
|
21
30
|
activeListingCount: number;
|
|
31
|
+
/** New in the last 24h, listed in full up to CONTACT_PREVIEW_LIMIT. */
|
|
32
|
+
contactMessages: DigestContactMessage[];
|
|
33
|
+
contactTotal: number;
|
|
34
|
+
/** New tickets in 24h, plus the standing backlog by status. */
|
|
35
|
+
newTicketCount: number;
|
|
36
|
+
ticketsOpen: number;
|
|
37
|
+
ticketsInProgress: number;
|
|
38
|
+
ticketsWaitingOnUser: number;
|
|
39
|
+
newScamReportCount: number;
|
|
40
|
+
/** Orders sitting in the manual-payment pending state (proof upload / review). */
|
|
41
|
+
manualPaymentPendingCount: number;
|
|
42
|
+
/** Count only — subscriber emails are PII-encrypted with an HMAC blind index. */
|
|
43
|
+
newSubscriberCount: number;
|
|
44
|
+
/** Privileged admin actions recorded in 24h — replaces the per-save settings email. */
|
|
45
|
+
adminActionCount: number;
|
|
46
|
+
unreadAdminNotificationCount: number;
|
|
22
47
|
}
|
|
23
48
|
/** Gathers the last-24h numbers. Exported so the admin manual-trigger route can reuse it. */
|
|
24
49
|
export declare function collectDailyStatusSummary(): Promise<DailyStatusDigestSummary>;
|
|
@@ -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,
|