@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.
Files changed (58) hide show
  1. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +18 -3
  2. package/dist/_internal/client/features/layout/filterNavItems.d.ts +22 -1
  3. package/dist/_internal/client/features/layout/filterNavItems.js +26 -1
  4. package/dist/_internal/server/features/checkout/actions.js +28 -22
  5. package/dist/_internal/server/features/media/contextGuards.d.ts +1 -1
  6. package/dist/_internal/server/features/media/contextGuards.js +34 -1
  7. package/dist/_internal/server/features/orders/actions.js +58 -49
  8. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  9. package/dist/_internal/server/features/site-settings/adapters.js +8 -0
  10. package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
  11. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
  12. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
  13. package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
  14. package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
  15. package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
  16. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
  17. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
  18. package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
  19. package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
  20. package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
  21. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
  22. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
  23. package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
  24. package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
  25. package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
  26. package/dist/_internal/server/notifications/send-guard.js +142 -0
  27. package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
  28. package/dist/_internal/shared/features/messaging/config.js +44 -0
  29. package/dist/_internal/shared/features/notifications/action-url.js +9 -0
  30. package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
  31. package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
  32. package/dist/constants/api-endpoints.d.ts +30 -0
  33. package/dist/constants/api-endpoints.js +10 -0
  34. package/dist/constants/notification-types.d.ts +1 -1
  35. package/dist/features/admin/actions/notification-actions.d.ts +20 -0
  36. package/dist/features/admin/actions/notification-actions.js +43 -1
  37. package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
  38. package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
  39. package/dist/features/admin/schemas/firestore.d.ts +32 -2
  40. package/dist/features/admin/schemas/firestore.js +28 -0
  41. package/dist/features/auth/hooks/useAuth.d.ts +5 -0
  42. package/dist/features/auth/hooks/useAuth.js +42 -0
  43. package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
  44. package/dist/features/contact/email.d.ts +30 -26
  45. package/dist/features/contact/email.js +65 -105
  46. package/dist/features/email/notification-templates.js +11 -0
  47. package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
  48. package/dist/features/messaging/repository/message-budget.repository.js +138 -0
  49. package/dist/features/messaging/schemas/firestore.d.ts +62 -0
  50. package/dist/features/messaging/schemas/firestore.js +36 -0
  51. package/dist/features/products/components/CardStoreLine.js +22 -3
  52. package/dist/features/seller/components/SellerProductShell.js +7 -0
  53. package/dist/features/tester/seed-data/authored/seo__page-metadata.js +6 -6
  54. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
  55. package/dist/seed/site-settings-seed-data.js +39 -10
  56. package/dist/server.d.ts +3 -5
  57. package/dist/server.js +27 -13
  58. package/package.json +1 -1
@@ -122,13 +122,28 @@ function useResponsiveDrawer(storageKey) {
122
122
  }
123
123
  /** Filter nav groups by navConfig (enabled toggle) + requiredPermission. */
124
124
  function filterGroups(groups, navConfig, permissions) {
125
- // null permissions = admin (show everything); undefined = no filtering (backwards compat)
126
- if (permissions === null && !navConfig)
125
+ /*
126
+ * `permissions === null` MEANS ADMIN, and that must hold whether or not a
127
+ * navConfig exists.
128
+ *
129
+ * The early return below encoded the convention correctly and then applied it
130
+ * only when `!navConfig`. The moment site settings carried ANY nav config,
131
+ * an admin fell through to `filterNavItems(..., undefined)` — and
132
+ * `undefined?.includes(x)` is falsy, so every item carrying a
133
+ * `requiredPermission` was dropped. The Admin Panel sidebar rendered empty
134
+ * while every admin page stayed reachable by URL.
135
+ *
136
+ * Passing `isAdmin` keeps the admin's navConfig toggles working (disabling an
137
+ * entry is a configuration decision, not a permission) while granting the
138
+ * permissions an admin implicitly holds.
139
+ */
140
+ const isAdmin = permissions === null;
141
+ if (isAdmin && !navConfig)
127
142
  return groups;
128
143
  return groups
129
144
  .map((group) => ({
130
145
  ...group,
131
- items: filterNavItems(group.items, navConfig, permissions ?? undefined),
146
+ items: filterNavItems(group.items, navConfig, permissions ?? undefined, isAdmin),
132
147
  }))
133
148
  .filter((group) => group.items.length > 0);
134
149
  }
@@ -8,8 +8,29 @@ type FilterableItem = {
8
8
  * 2. requiredPermission — item hidden when user lacks the permission
9
9
  *
10
10
  * Items without an `id` always pass through (legacy / unmanaged items).
11
+ *
12
+ * 🛑 A FULL ADMIN HOLDS EVERY PERMISSION IMPLICITLY — `permissions[]` REFINES
13
+ * EMPLOYEES, IT DOES NOT DEFINE ADMINS.
14
+ *
15
+ * That is how the rest of the system already works: every admin route guard is
16
+ * `roles: ROLES_ADMIN_ONLY`, and employees are the ones who carry explicit
17
+ * grants (the seeded employees hold 5 and 24 permissions respectively, while
18
+ * `user-admin-letitrip` holds NONE — matching its seed, so this is by design
19
+ * rather than drift). There is no role→permission derivation anywhere in the
20
+ * codebase for the nav to lean on.
21
+ *
22
+ * Without `isAdmin` here, an admin matched no `requiredPermission` and EVERY
23
+ * admin nav item was filtered out: the Admin Panel sidebar rendered completely
24
+ * empty, and searching it returned "No matches". Every admin page stayed
25
+ * reachable by typing its URL, which is exactly what made it look cosmetic.
26
+ *
27
+ * This surfaced only recently because the check had never run: `filterNavItems`
28
+ * opens with `if (!item.id) return true;` and no item carried an `id` until
29
+ * ids were derived (see navigation.tsx). Turning a dormant check on without the
30
+ * data to satisfy it is the same shape as seeding a ratchet from the wrong
31
+ * measurement — Root Cause #84.
11
32
  */
12
33
  export declare function filterNavItems<T extends FilterableItem>(items: T[], navConfig: Record<string, {
13
34
  enabled: boolean;
14
- }> | undefined, userPermissions: string[] | undefined): T[];
35
+ }> | undefined, userPermissions: string[] | undefined, isAdmin?: boolean): T[];
15
36
  export {};
@@ -4,13 +4,38 @@
4
4
  * 2. requiredPermission — item hidden when user lacks the permission
5
5
  *
6
6
  * Items without an `id` always pass through (legacy / unmanaged items).
7
+ *
8
+ * 🛑 A FULL ADMIN HOLDS EVERY PERMISSION IMPLICITLY — `permissions[]` REFINES
9
+ * EMPLOYEES, IT DOES NOT DEFINE ADMINS.
10
+ *
11
+ * That is how the rest of the system already works: every admin route guard is
12
+ * `roles: ROLES_ADMIN_ONLY`, and employees are the ones who carry explicit
13
+ * grants (the seeded employees hold 5 and 24 permissions respectively, while
14
+ * `user-admin-letitrip` holds NONE — matching its seed, so this is by design
15
+ * rather than drift). There is no role→permission derivation anywhere in the
16
+ * codebase for the nav to lean on.
17
+ *
18
+ * Without `isAdmin` here, an admin matched no `requiredPermission` and EVERY
19
+ * admin nav item was filtered out: the Admin Panel sidebar rendered completely
20
+ * empty, and searching it returned "No matches". Every admin page stayed
21
+ * reachable by typing its URL, which is exactly what made it look cosmetic.
22
+ *
23
+ * This surfaced only recently because the check had never run: `filterNavItems`
24
+ * opens with `if (!item.id) return true;` and no item carried an `id` until
25
+ * ids were derived (see navigation.tsx). Turning a dormant check on without the
26
+ * data to satisfy it is the same shape as seeding a ratchet from the wrong
27
+ * measurement — Root Cause #84.
7
28
  */
8
- export function filterNavItems(items, navConfig, userPermissions) {
29
+ export function filterNavItems(items, navConfig, userPermissions, isAdmin = false) {
9
30
  return items.filter((item) => {
10
31
  if (!item.id)
11
32
  return true;
33
+ // The admin toggle still applies to admins — disabling a nav entry is a
34
+ // deliberate configuration choice, not a permission the admin outranks.
12
35
  if (!(navConfig?.[item.id]?.enabled ?? true))
13
36
  return false;
37
+ if (isAdmin)
38
+ return true;
14
39
  if (item.requiredPermission && !userPermissions?.includes(item.requiredPermission))
15
40
  return false;
16
41
  return true;
@@ -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
- // Best-effort admin fan-out — mirrors onScamReportCreate's employee
1103
- // notification pattern. Never allowed to throw past this point.
1104
- try {
1105
- const admins = await userRepository.list({ filters: "role==admin", page: 1, pageSize: 100 });
1106
- await Promise.all(admins.items
1107
- .filter((a) => !!a.id)
1108
- .map((admin) => sendNotification({
1109
- userId: admin.id,
1110
- type: "system",
1111
- priority: "high",
1112
- title: "Automatic refund failed",
1113
- message: `Automatic refund failed for order ${primaryOrderId} (${unavailablePaid.length} unavailable item(s)) — manual refund required.`,
1114
- relatedId: primaryOrderId,
1115
- relatedType: "order",
1116
- userEmail: admin.email ?? undefined,
1117
- userPhone: admin.phoneNumber ?? undefined,
1118
- }).catch((notifErr) => serverLogger.error("Failed to notify admin of failed auto-refund (non-fatal)", notifErr))));
1119
- }
1120
- catch (notifyErr) {
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 query admins for failed auto-refund notification", { orderId: primaryOrderId });
1123
- }
1128
+ serverLogger.error("verifyAndPlaceRazorpayOrderAction: failed to write admin notification for failed auto-refund", { orderId: primaryOrderId });
1129
+ });
1124
1130
  }
1125
1131
  }
1126
1132
  /**
@@ -49,5 +49,5 @@ interface GuardInput {
49
49
  * derive the SEO filename. Mirrors the inline logic that used to live
50
50
  * in /api/media/upload.
51
51
  */
52
- export declare function applyMediaContextGuards({ detectedMime, context: ctx, }: GuardInput): GuardResult;
52
+ export declare function applyMediaContextGuards(input: GuardInput): GuardResult;
53
53
  export {};
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { classifyMime, MIME_TO_EXT, PRODUCT_IMAGE_INDEX_MAX, PRODUCT_MAX_VIDEOS, } from "../../../shared/media/limits";
13
13
  import { generateMediaFilename, validateMediaFilename, } from "../../../../utils/id-generators";
14
+ import { normalizeError } from "../../../../errors/normalize";
14
15
  /**
15
16
  * W1-51 — validate the generated filename before returning ok:true so a
16
17
  * malformed name can never enter signed-URL storage. Generators producing
@@ -89,7 +90,39 @@ function indexGuard(receivedIndex, max, errorMessage) {
89
90
  * derive the SEO filename. Mirrors the inline logic that used to live
90
91
  * in /api/media/upload.
91
92
  */
92
- export function applyMediaContextGuards({ detectedMime, context: ctx, }) {
93
+ /*
94
+ * 🛑 A MISSING CONTEXT FIELD IS A 400, NEVER A 500.
95
+ *
96
+ * The filename generators slugify their inputs, and `slugify(undefined)` throws
97
+ * `Cannot read properties of undefined (reading 'toLowerCase')` from deep inside
98
+ * `id-generators`. That escaped `applyMediaContextGuards` as an unhandled throw,
99
+ * so `/api/media/sign` answered 500 INTERNAL — twice in production — for what is
100
+ * simply a caller that forgot a field. The guards below validate `index` but
101
+ * have never validated the string fields, and enumerating every generator's
102
+ * requirements here would drift from the generators themselves (Root Cause #61).
103
+ *
104
+ * So the boundary catches instead: whatever the generator needs, failing to
105
+ * supply it now returns an actionable 400 naming the context type, and the real
106
+ * message is logged rather than shown. The caller-side fix still matters — this
107
+ * only guarantees the failure mode is honest.
108
+ */
109
+ export function applyMediaContextGuards(input) {
110
+ try {
111
+ return applyMediaContextGuardsInner(input);
112
+ }
113
+ catch (err) {
114
+ const normalized = normalizeError(err);
115
+ const message = normalized.message;
116
+ return {
117
+ ok: false,
118
+ status: 400,
119
+ error: `The upload context for "${input.context?.type ?? "unknown"}" is missing a required field. ` +
120
+ `Check every field its filename pattern needs.`,
121
+ details: { context: input.context?.type, thrown: message },
122
+ };
123
+ }
124
+ }
125
+ function applyMediaContextGuardsInner({ detectedMime, context: ctx, }) {
93
126
  const kind = classifyMime(detectedMime);
94
127
  if (!kind) {
95
128
  return {
@@ -1,6 +1,6 @@
1
1
  "use server";
2
2
  import { wrapAction } from "@mohasinac/appkit/server";
3
- import { orderRepository, storeRepository, userRepository } from "../../../../repositories";
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 WhatsApp push — fans out to every configured admin number so
119
- * a payment proof can be acted on within the 2-hour auto-approve window
120
- * without hunting through the orders list. Fire-and-forget, non-fatal on
121
- * failure (mirrors the existing `onOrderCreate.ts` purchase-announcement
122
- * pattern) — skips silently if the WhatsApp Cloud API isn't configured.
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 [{ resolveKeys }, { sendWhatsAppBusinessMessage, buildPaymentProofReviewMessage }] = await Promise.all([
128
- import("../../../../core/integration-keys"),
129
- import("../../../../features/whatsapp-bot/server"),
130
- ]);
131
- const keys = await resolveKeys();
132
- if (!keys.whatsappPhoneNumberId || !keys.whatsappCloudApiToken)
133
- return;
134
- const adminNumbers = keys.whatsappAdminNotifyNumbers
135
- .split(",")
136
- .map((n) => n.trim().replace(/\D/g, ""))
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
- // Non-fatal — the order itself already saved successfully; a WhatsApp
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
- // Notify every admin for manual investigation — a dispute on an
388
- // auto-approved order means nobody has looked at it yet.
389
- const admins = await userRepository.findByRole("admin");
390
- await Promise.allSettled(admins.map((admin) => sendNotification({
391
- userId: admin.uid,
392
- type: "payment_review",
393
- priority: "high",
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
- message: `A dispute was raised on order "${order.productTitle}" (auto-approved payment). Reason: ${reason}.`,
396
- relatedId: orderId,
397
- relatedType: "order",
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>;