@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
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Text } from "../../../ui/components/Typography";
|
|
4
4
|
import { safeDisplayName } from "../../../security/pii-redact";
|
|
5
|
+
/**
|
|
6
|
+
* `store-beyblade-arena` → `Beyblade Arena`.
|
|
7
|
+
*
|
|
8
|
+
* The slug fallback was described as "readable", and next to a raw Firestore id
|
|
9
|
+
* it is — but a buyer reading "by store-beyblade-arena" under a product does not
|
|
10
|
+
* see a readable name, they see a leaked identifier. Pre-order and auction
|
|
11
|
+
* documents do not denormalize `storeName`, so this fallback is what a large
|
|
12
|
+
* share of cards actually render, on every listing page.
|
|
13
|
+
*
|
|
14
|
+
* Humanising here fixes all six card variants at once and needs no data change.
|
|
15
|
+
* Denormalising `storeName` onto those documents is the better long-term fix and
|
|
16
|
+
* would simply stop this path being reached.
|
|
17
|
+
*/
|
|
18
|
+
function humanizeStoreSlug(slug) {
|
|
19
|
+
return slug
|
|
20
|
+
.replace(/^store-/, "")
|
|
21
|
+
.split("-")
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
24
|
+
.join(" ");
|
|
25
|
+
}
|
|
5
26
|
export function CardStoreLine({ storeName, storeId, className }) {
|
|
6
27
|
if (!storeId && !storeName)
|
|
7
28
|
return null;
|
|
8
29
|
// `safeDisplayName` also guards the encrypted-value case: a PII-ciphertext
|
|
9
30
|
// string reaching a card must render as a label, never as `enc:v1:…`.
|
|
10
|
-
|
|
11
|
-
// resort rather than an opaque key.
|
|
12
|
-
const seller = safeDisplayName(storeName, storeId ?? "Seller");
|
|
31
|
+
const seller = safeDisplayName(storeName, storeId ? humanizeStoreSlug(storeId) : "Seller");
|
|
13
32
|
return (_jsxs(Text, { className: className ?? "mt-0.5 text-[11px]", color: "faint", children: ["by ", seller] }));
|
|
14
33
|
}
|
|
@@ -147,6 +147,13 @@ function StepMedia({ values, onChange, storeSlug = "store", }) {
|
|
|
147
147
|
}), onChangeField: (media) => onChange({ video: media ?? undefined }), onThumbnailChange: (thumbnailUrl) => onChange({ video: values.video ? { ...values.video, thumbnailUrl } : undefined }), onDurationChange: (duration) => onChange({ video: values.video ? { ...values.video, duration } : undefined }), onUpload: (file) => upload(file, "products", true, {
|
|
148
148
|
type: "product-video",
|
|
149
149
|
name: values.title ?? "product",
|
|
150
|
+
// `category` is REQUIRED — generateProductVideoFilename slugifies it,
|
|
151
|
+
// and slugify(undefined) threw `Cannot read properties of undefined
|
|
152
|
+
// (reading 'toLowerCase')` inside the generator, surfacing as a 500
|
|
153
|
+
// from /api/media/sign. Every product video upload from this panel
|
|
154
|
+
// failed that way; ProductForm's gallery path passed it and worked,
|
|
155
|
+
// which is why only the dedicated video tab was broken.
|
|
156
|
+
category: values.category || "uncategorized",
|
|
150
157
|
store: storeSlug,
|
|
151
158
|
}), kind: "auto", helperText: "The gallery above accepts a video file directly. Use this panel for a YouTube or external URL, or to trim and pick a poster frame." }), _jsx(FormField, { name: "externalVideoUrl", label: "3rd-party video URL (YouTube / Vimeo)", type: "text", value: values.externalVideoUrl ?? "", onChange: (v) => onChange({ externalVideoUrl: v }), placeholder: "https://www.youtube.com/watch?v=\u2026", hint: "External video links are queued for moderation and become visible to buyers after admin approval." }), _jsx(FormField, { name: "youtubeId", label: "YouTube Video ID (optional)", type: "text", value: values.youtubeId ?? "", onChange: (v) => onChange({ youtubeId: v }), placeholder: "e.g. dQw4w9WgXcQ", helpText: "Paste the 11-character ID from the YouTube URL" })] }));
|
|
152
159
|
}
|
|
@@ -95,14 +95,14 @@ export const authored = {
|
|
|
95
95
|
startPage: "/products",
|
|
96
96
|
steps: [
|
|
97
97
|
"Open the network panel and navigate to a product path whose trailing segment is a nonsense word no listing uses.",
|
|
98
|
-
"Read the status code of the document response.",
|
|
99
98
|
"Read what the page shows.",
|
|
100
|
-
"View the source and
|
|
101
|
-
"Repeat with a nonsense store path and a nonsense
|
|
99
|
+
"View the source and read every robots meta tag.",
|
|
100
|
+
"Repeat with a nonsense store path and a nonsense auction path.",
|
|
101
|
+
"Open a REAL product page and read its robots meta tags too.",
|
|
102
102
|
],
|
|
103
|
-
expectedBehaviour: "A missing page
|
|
104
|
-
expectedUiState: "
|
|
105
|
-
expectedData: {
|
|
103
|
+
expectedBehaviour: "A missing page renders the site's 404 view and marks itself NOINDEX. Judge the noindex, not the status code: Next streams the response, so the headers are already sent by the time the not-found is raised and the status can no longer be changed. It injects a noindex robots tag into the streamed HTML instead, and that is what actually keeps the URL out of the index. A 200 here is expected and is not the finding.",
|
|
104
|
+
expectedUiState: "All three missing paths render the 404 view and their source carries a robots tag whose content is noindex. The real product page carries no noindex — check it, because a blanket noindex would satisfy the first half while quietly de-indexing the whole catalogue.",
|
|
105
|
+
expectedData: { missingPageIsNoindex: true, realPageIsNoindex: false },
|
|
106
106
|
endResult: "Read-only.",
|
|
107
107
|
},
|
|
108
108
|
"checklist-seo-page-metadata-title-length-reasonable": {
|
|
@@ -2838,18 +2838,57 @@ const rawTesterChecklistItems = [
|
|
|
2838
2838
|
{ key: "receive-notification", label: "In-app notifications appear for order/bid/message events", href: "/user/notifications" },
|
|
2839
2839
|
{ key: "mark-read", label: "Marking a notification as read works" },
|
|
2840
2840
|
{ key: "notification-channel-prefs", label: "Per-channel notification preferences (in-app / email / WhatsApp) save and are respected" },
|
|
2841
|
-
{
|
|
2841
|
+
{
|
|
2842
|
+
key: "notification-type-sample",
|
|
2843
|
+
label: "A sample of notification types each trigger correctly end-to-end — and the bell-only ones produce NO email",
|
|
2844
|
+
description: "Rewritten 2026-09. This case used to list \"bid outbid\" as something that should trigger end-to-end including email; it is now bell-only. Trigger one of each and check both surfaces: an ORDER STATUS CHANGE must appear in the bell AND (with email enabled) arrive in the inbox; BEING OUTBID must appear in the bell and produce NO email at all. If being outbid still emails, the eligibility gate is not running.",
|
|
2845
|
+
},
|
|
2842
2846
|
{ key: "notification-tab-filters", label: "Notification tab filters correctly narrow the list", href: "/user/notifications" },
|
|
2843
2847
|
{
|
|
2844
2848
|
key: "notification-email-actually-arrives",
|
|
2845
2849
|
label: "With email enabled for a notification type, the email really lands in your inbox — and comes from the site's configured sender name and address",
|
|
2846
|
-
description: "
|
|
2850
|
+
description: "Updated 2026-09 — the type now matters. Most notification types are bell-only and can never email regardless of settings, so this case MUST use an email-eligible one: use ORDER SHIPPED (have a seller mark your order shipped). Do not use \"outbid\", \"auction ended\", \"review replied\" or a promotion — those are bell-only by design and would make a working system look broken. First confirm Site Settings → Notifications → \"Send user-facing email\" is ON, then trigger the event and open the real inbox for the address on your account. Confirm: (a) an email arrives within a few minutes; (b) the From name and address match what Site Settings is configured with, not a stranger's domain or a bare no-reply; (c) it landed in the inbox, not spam; (d) the links inside it open the right page on the live site and don't 404.",
|
|
2847
2851
|
href: "/user/notifications",
|
|
2848
2852
|
},
|
|
2849
2853
|
{
|
|
2850
2854
|
key: "notification-email-opt-out-respected",
|
|
2851
2855
|
label: "Turning email OFF for a notification type stops the emails but still shows the in-app notification",
|
|
2852
|
-
description: "
|
|
2856
|
+
description: "Updated 2026-09. Use an email-ELIGIBLE type or this case proves nothing — order shipped is the reliable one. A bell-only type (outbid, auction ended, review replied, promotion) never emails anyway, so turning the preference off would 'pass' while the preference was doing no work at all. The two channels are meant to be independent: opting out of email must never silently opt you out of the bell. Turn email off for order updates, have a seller mark an order shipped, then confirm: no new email arrives, but the notification still appears in /user/notifications. Turn it back on, trigger again, confirm the email resumes — an opt-out that cannot be reversed is just as much a bug.",
|
|
2857
|
+
href: "/user/notifications",
|
|
2858
|
+
},
|
|
2859
|
+
{
|
|
2860
|
+
key: "notification-ineligible-types-bell-only",
|
|
2861
|
+
label: "Being outbid fills the notification bell and sends NO email",
|
|
2862
|
+
description: "Added 2026-09. THE case for this change. BEFORE: every outbid sent an email — one per bid per bidder on a live auction — and losing an auction sent one to every losing bidder, up to 50 in parallel per auction. On a 100-emails-a-day allowance two auctions could consume the entire day. AFTER: the notification is written to the bell exactly as before, and no email leaves. Nothing is lost; only the emailed copy is gone.",
|
|
2863
|
+
roles: ["buyer"],
|
|
2864
|
+
startPage: "/user/notifications",
|
|
2865
|
+
steps: [
|
|
2866
|
+
"Log in as a buyer.",
|
|
2867
|
+
"Open a tester-sandbox auction and place a bid.",
|
|
2868
|
+
"From a second account, place a higher bid on the same auction.",
|
|
2869
|
+
"Back on the first account, open /user/notifications.",
|
|
2870
|
+
"Open the real inbox for the address on the first account.",
|
|
2871
|
+
],
|
|
2872
|
+
expectedBehaviour: "An in-app notification row is written for the outbid. No email is sent, and no suppression error is raised — this is the designed path, not a failure.",
|
|
2873
|
+
expectedUiState: "The bell shows an outbid notification naming the auction. The inbox has no new message about it.",
|
|
2874
|
+
endResult: "After a full page reload the bell notification is still listed. Still no email.",
|
|
2875
|
+
href: "/user/notifications",
|
|
2876
|
+
},
|
|
2877
|
+
{
|
|
2878
|
+
key: "notification-losing-bidder-no-email",
|
|
2879
|
+
label: "When an auction settles, every losing bidder gets a bell entry and zero emails — but the WINNER still gets an email",
|
|
2880
|
+
description: "Added 2026-09. The second half is what makes this case worth running: a change that merely broke email would pass a losers-only check. Winning carries a payment deadline and forfeits the lot if missed, so 'bid won' is deliberately still an email-eligible type while 'bid lost' is not.",
|
|
2881
|
+
roles: ["buyer", "admin"],
|
|
2882
|
+
startPage: "/user/notifications",
|
|
2883
|
+
steps: [
|
|
2884
|
+
"Ensure a tester-sandbox auction has bids from at least two different accounts.",
|
|
2885
|
+
"Let the auction end, or have an admin settle it.",
|
|
2886
|
+
"Check the bell and inbox of a LOSING bidder.",
|
|
2887
|
+
"Check the bell and inbox of the WINNING bidder.",
|
|
2888
|
+
],
|
|
2889
|
+
expectedBehaviour: "Losing bidders each get an in-app notification and no email. The winner gets both an in-app notification and an email carrying a link to complete checkout.",
|
|
2890
|
+
expectedUiState: "Loser: bell shows the auction closed without them, inbox empty. Winner: bell shows they won, and an email arrives whose call to action goes to checkout, not to the product page.",
|
|
2891
|
+
endResult: "Both bell entries survive a reload. Exactly one email was sent for the whole settlement, to the winner.",
|
|
2853
2892
|
href: "/user/notifications",
|
|
2854
2893
|
},
|
|
2855
2894
|
{
|
|
@@ -3084,6 +3123,126 @@ const rawTesterChecklistItems = [
|
|
|
3084
3123
|
description: "Converted 2026-08-21 off a hand-wired toolbar (which had status filter + \"hide resolved/closed\" toggle + sort, but no search input) onto the standard DataListingView scaffold, which adds search for free. Confirm typing part of a ticket's subject narrows the list, and the existing status filter + \"Hide resolved/closed\" toggle still work.",
|
|
3085
3124
|
href: "/user/support",
|
|
3086
3125
|
},
|
|
3126
|
+
{
|
|
3127
|
+
key: "ticket-reply-no-email-by-default",
|
|
3128
|
+
label: "A staff reply on a ticket reaches the user in-app and sends NO email unless the box is ticked",
|
|
3129
|
+
description: "Added 2026-09. Ticket conversation lives in the UI now — routine back-and-forth costs nothing against the daily allowance, and the tick is the deliberate exception for a reply that genuinely has to reach an inbox.",
|
|
3130
|
+
roles: ["admin", "buyer"],
|
|
3131
|
+
startPage: "/admin/support-tickets",
|
|
3132
|
+
steps: [
|
|
3133
|
+
"Log in as admin and open an existing support ticket from the list.",
|
|
3134
|
+
"Type a reply in the \"Reply to user\" box.",
|
|
3135
|
+
"Leave \"Also email the user about this reply\" unticked.",
|
|
3136
|
+
"Send the reply.",
|
|
3137
|
+
"Log in as the ticket's owner and open /user/support.",
|
|
3138
|
+
"Open the real inbox for that account's address.",
|
|
3139
|
+
"Back as admin, reply again on the same ticket with the checkbox TICKED.",
|
|
3140
|
+
"Check the owner's inbox again.",
|
|
3141
|
+
],
|
|
3142
|
+
expectedBehaviour: "Both replies are saved to the ticket and both produce an in-app notification. Only the second sends an email. The checkbox resets itself to unticked after each send.",
|
|
3143
|
+
expectedUiState: "The ticket thread shows both replies. After the first, the inbox has nothing new; after the second, exactly one email arrives. The checkbox is unticked again when the reply box clears.",
|
|
3144
|
+
endResult: "After reloading the ticket, both replies are still there. Exactly one email was sent across the two.",
|
|
3145
|
+
href: "/user/support",
|
|
3146
|
+
},
|
|
3147
|
+
{
|
|
3148
|
+
key: "ticket-reply-user-cannot-force-email",
|
|
3149
|
+
label: "A USER reply cannot trigger an email even when the request asks for one",
|
|
3150
|
+
description: "Added 2026-09. ENGINEERING-ASSISTED — it cannot be driven from the UI, because the user-side reply box has no such checkbox. That is exactly the point: the restriction is enforced on the SERVER from the session, not by the absence of a control.",
|
|
3151
|
+
roles: ["buyer"],
|
|
3152
|
+
startPage: "/user/support",
|
|
3153
|
+
steps: [
|
|
3154
|
+
"Log in as a buyer who owns at least one open support ticket and open it.",
|
|
3155
|
+
"Open the browser console.",
|
|
3156
|
+
// audit-hardcoded-api-routes-ok: prose in a tester-facing step (a copy-pasteable console snippet), not a call site — nothing to route through the registry.
|
|
3157
|
+
"Post a reply with the email flag forced on: fetch('/api/support/tickets/<ticketId>/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({body:'probe',sendEmail:true})})",
|
|
3158
|
+
"Open the real inbox for that account's address.",
|
|
3159
|
+
],
|
|
3160
|
+
inputs: { sendEmail: true, body: "probe" },
|
|
3161
|
+
expectedBehaviour: "The message posts successfully (201). The sendEmail flag is ignored because the author is not staff, so no email is dispatched to anyone.",
|
|
3162
|
+
expectedUiState: "The reply appears in the ticket thread. The inbox has nothing new.",
|
|
3163
|
+
endResult: "After a reload the reply is still in the thread, and still no email was sent.",
|
|
3164
|
+
href: "/user/support",
|
|
3165
|
+
},
|
|
3166
|
+
{
|
|
3167
|
+
key: "ticket-created-reaches-staff",
|
|
3168
|
+
label: "Raising a support ticket now produces a staff signal — it previously produced none at all",
|
|
3169
|
+
description: "Added 2026-09. BEFORE: onSupportTicketCreate notified only the ticket's own author, so nobody on staff was told a ticket existed; you found out by going to look. AFTER: it writes one admin-inbox row and rides the daily digest.",
|
|
3170
|
+
roles: ["buyer", "admin"],
|
|
3171
|
+
startPage: "/user/support",
|
|
3172
|
+
steps: [
|
|
3173
|
+
"Log in as a buyer and raise a new support ticket.",
|
|
3174
|
+
"Log in as admin and open the admin notifications inbox.",
|
|
3175
|
+
"Trigger the daily digest and read its Inbox section.",
|
|
3176
|
+
],
|
|
3177
|
+
expectedBehaviour: "One admin notification row is created for the new ticket — not one per staff member — and the ticket is counted in the digest's new-ticket total and in the open/in-progress/waiting breakdown.",
|
|
3178
|
+
expectedUiState: "The admin notifications inbox lists the new ticket. The digest's Inbox section shows the counts moving by one.",
|
|
3179
|
+
endResult: "The admin notification row persists after a reload and stays until someone marks it read.",
|
|
3180
|
+
href: "/user/support",
|
|
3181
|
+
},
|
|
3182
|
+
],
|
|
3183
|
+
},
|
|
3184
|
+
{
|
|
3185
|
+
pageKey: "contact-inbox",
|
|
3186
|
+
pageLabel: "Contact messages (admin inbox)",
|
|
3187
|
+
href: "/admin/contact",
|
|
3188
|
+
cases: [
|
|
3189
|
+
{
|
|
3190
|
+
key: "contact-saves-without-email",
|
|
3191
|
+
label: "Submitting the contact form stores the message and sends no email",
|
|
3192
|
+
description: "Added 2026-09. BEFORE: each submission emailed a support address — and EMAIL_SUPPORT was never configured in any runtime, so every contact email went to an example.com domain nobody owns. AFTER: the message is a record, read from the admin inbox and listed in the daily digest.",
|
|
3193
|
+
roles: ["guest", "admin"],
|
|
3194
|
+
startPage: "/contact",
|
|
3195
|
+
steps: [
|
|
3196
|
+
"Open the public contact page as a signed-out visitor.",
|
|
3197
|
+
"Fill in name, email, subject and a message of at least 10 characters.",
|
|
3198
|
+
"Submit the form.",
|
|
3199
|
+
"Log in as admin and open /admin/contact.",
|
|
3200
|
+
"Check the inbox of whatever address used to receive support mail.",
|
|
3201
|
+
],
|
|
3202
|
+
inputs: {
|
|
3203
|
+
name: "Tester Probe",
|
|
3204
|
+
email: "probe@example.com",
|
|
3205
|
+
subject: "Digest probe",
|
|
3206
|
+
message: "Checking the contact form stores without emailing.",
|
|
3207
|
+
},
|
|
3208
|
+
expectedBehaviour: "The submission is written to Firestore before the request returns. No email is sent to anyone.",
|
|
3209
|
+
expectedUiState: "The form shows its success message. /admin/contact lists the message with the name, subject and body exactly as typed. No new mail anywhere.",
|
|
3210
|
+
endResult: "After a reload the message is still in /admin/contact, and it appears in the next daily digest under Contact messages with a Reply link.",
|
|
3211
|
+
href: "/admin/contact",
|
|
3212
|
+
},
|
|
3213
|
+
{
|
|
3214
|
+
key: "contact-save-failure-is-loud",
|
|
3215
|
+
label: "If the contact message cannot be saved, the submitter is TOLD — it is never silently dropped",
|
|
3216
|
+
description: "Added 2026-09. ENGINEERING-ASSISTED — needs the Firestore write forced to fail. This guards the sharpest edge in the whole change: the save used to be fire-and-forget with the email as an unspoken backup, and with the email gone this write is the ONLY record that a customer wrote in. Telling someone to try again is strictly better than losing what they typed.",
|
|
3217
|
+
roles: ["guest"],
|
|
3218
|
+
startPage: "/contact",
|
|
3219
|
+
steps: [
|
|
3220
|
+
"Have an engineer force the contactSubmissions write to fail (e.g. temporarily deny the collection in rules).",
|
|
3221
|
+
"Submit the contact form as a signed-out visitor.",
|
|
3222
|
+
"Restore the write path afterwards.",
|
|
3223
|
+
],
|
|
3224
|
+
expectedBehaviour: "The request returns an error rather than a success. The submitter is not told their message was received when it was not.",
|
|
3225
|
+
expectedUiState: "The form shows a failure message, not the success confirmation.",
|
|
3226
|
+
endResult: "Nothing appears in /admin/contact for that attempt, and the visitor knows to try again.",
|
|
3227
|
+
href: "/admin/contact",
|
|
3228
|
+
},
|
|
3229
|
+
{
|
|
3230
|
+
key: "contact-digest-mailto-reply",
|
|
3231
|
+
label: "The digest's Reply link opens a mail client addressed to the person who wrote in",
|
|
3232
|
+
description: "Added 2026-09. This is the reply path. There is deliberately no in-app reply box for contact messages: replying from your own mailbox costs nothing against the send allowance, whereas an in-app reply would spend one.",
|
|
3233
|
+
roles: ["admin"],
|
|
3234
|
+
startPage: "/admin/contact",
|
|
3235
|
+
steps: [
|
|
3236
|
+
"Submit a contact message from the public form.",
|
|
3237
|
+
"As admin, trigger the daily digest.",
|
|
3238
|
+
"Open the digest and find the message under Contact messages.",
|
|
3239
|
+
"Click its Reply link.",
|
|
3240
|
+
],
|
|
3241
|
+
expectedBehaviour: "The link is a mailto: addressed to the submitter, with the subject pre-filled.",
|
|
3242
|
+
expectedUiState: "The local mail client opens a new message to the submitter's address with the subject \"Re: <their subject>\".",
|
|
3243
|
+
endResult: "No mail is sent by the site itself — the reply leaves from the operator's own mailbox.",
|
|
3244
|
+
href: "/admin/contact",
|
|
3245
|
+
},
|
|
3087
3246
|
],
|
|
3088
3247
|
},
|
|
3089
3248
|
{
|
|
@@ -4557,8 +4716,8 @@ const rawTesterChecklistItems = [
|
|
|
4557
4716
|
},
|
|
4558
4717
|
{
|
|
4559
4718
|
key: "whatsapp-order-announcement-fires",
|
|
4560
|
-
label: "Placing an order
|
|
4561
|
-
description: "
|
|
4719
|
+
label: "Placing an order writes ONE admin notification and WhatsApps only the seller — never the admin number list",
|
|
4720
|
+
description: "Rewritten 2026-09. BEFORE: every order WhatsApped every number in the admin notify list, the highest-volume staff blast in the codebase, and it was skipped entirely when Meta credentials were absent — so with no credentials an order produced no staff signal at all and no record that it hadn't. AFTER: place an order and confirm (a) an admin notification row appears for it (visible in the admin notifications inbox, and counted in the next daily digest) EVEN WITH NO META CREDENTIALS CONFIGURED; (b) no WhatsApp message reaches any admin number; (c) with credentials configured, the seller who owns the listing still receives their sale announcement — that one is a single message to one person about their own sale and is deliberately kept.",
|
|
4562
4721
|
href: "/admin/site",
|
|
4563
4722
|
},
|
|
4564
4723
|
{
|
|
@@ -4569,8 +4728,8 @@ const rawTesterChecklistItems = [
|
|
|
4569
4728
|
},
|
|
4570
4729
|
{
|
|
4571
4730
|
key: "daily-digest-recipients-save",
|
|
4572
|
-
label: "Site Settings → Notifications → \"Daily status digest\" saves its recipient
|
|
4573
|
-
description: "
|
|
4731
|
+
label: "Site Settings → Notifications → \"Daily status digest\" saves its recipient list, and there is NO CC field",
|
|
4732
|
+
description: "Rewritten 2026-09. BEFORE: the digest went to 3 addresses plus a CC, and this case told you to enter a CC address and check it round-tripped. Resend bills a CC as a separate delivery, so one daily report cost FOUR of the day's 100 emails. AFTER: the CC input is gone from the Notifications tab entirely, and saving clears any CC a previous document carried. Toggle the digest on, enter ONE address in Recipients, save, reload, and confirm: (a) the address comes back exactly as entered; (b) there is no \"CC recipients\" field anywhere in the digest block; (c) the recipients block is hidden when the toggle is off.",
|
|
4574
4733
|
href: "/admin/site",
|
|
4575
4734
|
},
|
|
4576
4735
|
{
|
|
@@ -4581,9 +4740,118 @@ const rawTesterChecklistItems = [
|
|
|
4581
4740
|
},
|
|
4582
4741
|
{
|
|
4583
4742
|
key: "daily-digest-email-content",
|
|
4584
|
-
label: "The daily status digest
|
|
4743
|
+
label: "The daily status digest arrives at ONE address with no CC, and carries the Inbox and Needs-attention sections in both the HTML and the plain-text part",
|
|
4744
|
+
description:
|
|
4585
4745
|
// audit-hardcoded-api-routes-ok: prose in a tester-facing description (a copy-pasteable console snippet), not a call site — nothing to route through the registry.
|
|
4586
|
-
|
|
4746
|
+
"Rewritten 2026-09. The digest is no longer an orders report — it is now the ONLY staff signal, carrying the contact messages, ticket backlog and payment-proof queue that each used to be their own email or WhatsApp blast. Trigger it on demand rather than waiting for 10:00 IST: while logged in as admin, run fetch('/api/admin/daily-digest/trigger',{method:'POST'}) in the browser console. Then confirm: (a) it arrives at exactly ONE address with NO CC — BEFORE it went to 3 recipients plus a CC, four billed sends for one report; (b) it still shows the 24h order count, revenue, active listings, pending-over-24h and the per-status breakdown, and those match the admin orders list; (c) there is an \"Inbox\" section with new contact messages, new support tickets, the open/in-progress/waiting ticket counts and unread admin notifications; (d) there is a \"Needs attention\" section with manual payments pending, new scam reports, new subscribers and admin actions logged; (e) if any contact messages arrived in the window, each is listed with a working Reply link that opens your mail client addressed to the sender. THE EASY ONE TO MISS: view the message's PLAIN-TEXT alternative (in Gmail: ⋮ → Show original) and confirm the Inbox and Needs-attention sections appear there too — the text part is built by a separate function and is exactly where a new section gets forgotten.",
|
|
4747
|
+
href: "/admin/site",
|
|
4748
|
+
},
|
|
4749
|
+
{
|
|
4750
|
+
key: "messaging-kill-switch-persists",
|
|
4751
|
+
label: "Site Settings → Notifications → \"Outbound messaging budget\" toggles and ceilings survive a save + reload",
|
|
4752
|
+
description: "Added 2026-09. THE RELOAD IS THE WHOLE TEST. A settings group missing from the route's write allow-list saves with a success toast and writes nothing — so a toggle that flips, saves and shows green proves nothing at all until you come back to it.",
|
|
4753
|
+
roles: ["admin"],
|
|
4754
|
+
startPage: "/admin/site",
|
|
4755
|
+
steps: [
|
|
4756
|
+
"Open Site Settings → Notifications.",
|
|
4757
|
+
"In \"Outbound messaging budget\", flip \"Send user-facing email\".",
|
|
4758
|
+
"Change the daily email ceiling to a distinctive number.",
|
|
4759
|
+
"Save.",
|
|
4760
|
+
"Hard-reload the page and reopen the same tab.",
|
|
4761
|
+
],
|
|
4762
|
+
inputs: { dailyEmailCeiling: 73 },
|
|
4763
|
+
expectedBehaviour: "Both values are written to siteSettings and read back on load.",
|
|
4764
|
+
expectedUiState: "After the reload the toggle is in the position you left it and the ceiling reads 73.",
|
|
4765
|
+
endResult: "The values persist across a full browser restart. Restore them afterwards.",
|
|
4766
|
+
href: "/admin/site",
|
|
4767
|
+
},
|
|
4768
|
+
{
|
|
4769
|
+
key: "messaging-kill-switch-suppresses-user-mail",
|
|
4770
|
+
label: "With user-facing email OFF, an order-shipped notification still fills the bell but sends no email",
|
|
4771
|
+
description: "Added 2026-09. NEGATIVE CONTROL CASE: a send returning success proves nothing here, because a suppressed send is deliberately NOT an error. The inbox is the only oracle.",
|
|
4772
|
+
roles: ["admin", "seller", "buyer"],
|
|
4773
|
+
startPage: "/admin/site",
|
|
4774
|
+
steps: [
|
|
4775
|
+
"Turn \"Send user-facing email\" OFF in Site Settings → Notifications and save.",
|
|
4776
|
+
"As a seller, mark a buyer's order shipped.",
|
|
4777
|
+
"As that buyer, open /user/notifications and the real inbox for the account's address.",
|
|
4778
|
+
"Turn the toggle back ON and save.",
|
|
4779
|
+
"Ship a second order and check the inbox again.",
|
|
4780
|
+
],
|
|
4781
|
+
expectedBehaviour: "With the switch off the notification is written but no email is dispatched. With it on, the email resumes — a kill switch that cannot be un-flipped is its own bug.",
|
|
4782
|
+
expectedUiState: "Off: bell shows the shipment, inbox has nothing new. On: bell shows it and an email arrives.",
|
|
4783
|
+
endResult: "Both bell entries survive a reload. Exactly one email was sent across the two shipments.",
|
|
4784
|
+
href: "/admin/site",
|
|
4785
|
+
},
|
|
4786
|
+
{
|
|
4787
|
+
key: "messaging-kill-switch-spares-staff",
|
|
4788
|
+
label: "With user-facing email OFF, the daily status digest STILL arrives",
|
|
4789
|
+
description: "Added 2026-09. The most important property of the kill switch: it suppresses user-facing mail only. Staff mail is exempt because the digest is now the only way a contact message, a new support ticket or a stuck payment proof gets noticed — switching off customer email must never leave the site unwatched.",
|
|
4790
|
+
roles: ["admin"],
|
|
4791
|
+
startPage: "/admin/site",
|
|
4792
|
+
steps: [
|
|
4793
|
+
"Confirm \"Send user-facing email\" is OFF in Site Settings → Notifications.",
|
|
4794
|
+
"Open the browser console as admin.",
|
|
4795
|
+
// audit-hardcoded-api-routes-ok: prose in a tester-facing step (a copy-pasteable console snippet), not a call site — nothing to route through the registry.
|
|
4796
|
+
"Run fetch('/api/admin/daily-digest/trigger',{method:'POST'})",
|
|
4797
|
+
"Check the configured digest recipient's inbox.",
|
|
4798
|
+
],
|
|
4799
|
+
expectedBehaviour: "The digest is sent despite the kill switch, because its audience is staff rather than user.",
|
|
4800
|
+
expectedUiState: "The digest email arrives with subject \"Daily Status\".",
|
|
4801
|
+
endResult: "Turning the switch back on changes nothing about the digest — it was never gated by it.",
|
|
4802
|
+
href: "/admin/site",
|
|
4803
|
+
},
|
|
4804
|
+
{
|
|
4805
|
+
key: "messaging-daily-ceiling-blocks",
|
|
4806
|
+
label: "Setting the daily email ceiling to 1 lets the first message through and blocks the second",
|
|
4807
|
+
description: "Added 2026-09. RESTORE THE CEILING TO 80 AFTERWARDS — the counter is per calendar day in IST, so leaving it at 1 silences the site until midnight.",
|
|
4808
|
+
roles: ["admin", "seller", "buyer"],
|
|
4809
|
+
startPage: "/admin/site",
|
|
4810
|
+
steps: [
|
|
4811
|
+
"Set \"Daily email ceiling\" to 1 and save. Ensure user-facing email is ON.",
|
|
4812
|
+
"Ship one order so an email-eligible notification fires.",
|
|
4813
|
+
"Ship a second order.",
|
|
4814
|
+
"Check the buyer inboxes for both.",
|
|
4815
|
+
"Set the ceiling back to 80 and save.",
|
|
4816
|
+
],
|
|
4817
|
+
inputs: { dailyEmailCeiling: 1 },
|
|
4818
|
+
expectedBehaviour: "The first send reserves the only unit and goes out. The second is refused by the budget and recorded as a suppression rather than vanishing silently.",
|
|
4819
|
+
expectedUiState: "One email arrives, the second does not. Both orders still show their notification in the bell.",
|
|
4820
|
+
endResult: "After restoring the ceiling, the next shipment emails normally again.",
|
|
4821
|
+
href: "/admin/site",
|
|
4822
|
+
},
|
|
4823
|
+
{
|
|
4824
|
+
key: "scam-report-no-employee-blast",
|
|
4825
|
+
label: "Filing a scam report creates ONE admin notification, not one per employee",
|
|
4826
|
+
description: "Added 2026-09. BEFORE: a single report sent one notification per employee, up to 100, in parallel — each a potential email, so one report could exhaust the day's allowance on its own.",
|
|
4827
|
+
roles: ["buyer", "admin"],
|
|
4828
|
+
startPage: "/scams",
|
|
4829
|
+
steps: [
|
|
4830
|
+
"As a signed-in buyer, file a scam report.",
|
|
4831
|
+
"As admin, open the admin notifications inbox.",
|
|
4832
|
+
"Check an employee account's notification bell and inbox.",
|
|
4833
|
+
"Trigger the daily digest and read Needs attention.",
|
|
4834
|
+
],
|
|
4835
|
+
expectedBehaviour: "Exactly one admin-notification row is written. The reporter still gets their own in-app confirmation. No employee receives a personal notification or an email.",
|
|
4836
|
+
expectedUiState: "One row in the admin inbox; the reporter's bell shows their confirmation; employee bells show nothing new.",
|
|
4837
|
+
endResult: "The digest's new-scam-report count has moved by one.",
|
|
4838
|
+
href: "/admin/site",
|
|
4839
|
+
},
|
|
4840
|
+
{
|
|
4841
|
+
key: "site-settings-save-sends-no-email",
|
|
4842
|
+
label: "Saving Site Settings sends NO email to anyone",
|
|
4843
|
+
description: "Added 2026-09. BEFORE: every single save emailed a hardcoded admin address, so a session of tuning fees spent one of the day's 100 emails per click. The record still exists and is better — /admin/audit-log is queryable in a way an email never was.",
|
|
4844
|
+
roles: ["admin"],
|
|
4845
|
+
startPage: "/admin/site",
|
|
4846
|
+
steps: [
|
|
4847
|
+
"Save Site Settings three or four times, changing something small each time.",
|
|
4848
|
+
"Check the inbox of any admin address that used to receive these.",
|
|
4849
|
+
"Open /admin/audit-log.",
|
|
4850
|
+
"Trigger the daily digest.",
|
|
4851
|
+
],
|
|
4852
|
+
expectedBehaviour: "No email is sent for any save. Each save is recorded in adminAuditLog instead.",
|
|
4853
|
+
expectedUiState: "No new mail. /admin/audit-log lists the saves with actor and changed fields.",
|
|
4854
|
+
endResult: "The digest's \"Admin actions logged\" count reflects the saves.",
|
|
4587
4855
|
href: "/admin/site",
|
|
4588
4856
|
},
|
|
4589
4857
|
{
|
|
@@ -191,19 +191,38 @@ export const siteSettingsSeedData = {
|
|
|
191
191
|
fromName: "LetItRip",
|
|
192
192
|
fromEmail: "noreply@letitrip.in",
|
|
193
193
|
replyTo: "support@letitrip.in",
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
/*
|
|
195
|
+
* ONE recipient, no CC.
|
|
196
|
+
*
|
|
197
|
+
* This was 3 TO plus 1 CC. Resend bills a CC as a separate delivery, so
|
|
198
|
+
* one daily report cost FOUR of the day's 100 emails — and the digest is
|
|
199
|
+
* now the single most important message the site sends, since it carries
|
|
200
|
+
* the contact messages, ticket backlog and payment-proof queue that used
|
|
201
|
+
* to be their own alerts.
|
|
202
|
+
*
|
|
203
|
+
* `ccRecipients` stays as an empty array rather than being removed: the
|
|
204
|
+
* field is still on the schema so existing documents validate, and
|
|
205
|
+
* `dailyStatusDigest` no longer reads it at all.
|
|
206
|
+
*/
|
|
197
207
|
dailyDigest: {
|
|
198
208
|
enabled: true,
|
|
199
|
-
recipients: [
|
|
200
|
-
|
|
201
|
-
"reply@letitrip.in",
|
|
202
|
-
"mohasin@letitrip.in",
|
|
203
|
-
],
|
|
204
|
-
ccRecipients: ["letitrip.in@gmail.com"],
|
|
209
|
+
recipients: ["mohasin@letitrip.in"],
|
|
210
|
+
ccRecipients: [],
|
|
205
211
|
},
|
|
206
212
|
},
|
|
213
|
+
/*
|
|
214
|
+
* Both channels start OFF. A reseed should leave the site quiet rather than
|
|
215
|
+
* mailing real people, and turning email back on is one toggle in
|
|
216
|
+
* Site Settings → Notifications.
|
|
217
|
+
*
|
|
218
|
+
* The ceiling is 80 against Resend's free 100/day — see
|
|
219
|
+
* MESSAGE_BUDGET_DEFAULTS for why the headroom is not timidity.
|
|
220
|
+
*/
|
|
221
|
+
messaging: {
|
|
222
|
+
emailEnabled: false,
|
|
223
|
+
whatsappEnabled: false,
|
|
224
|
+
dailyCeiling: { email: 80, whatsapp: 200, sms: 50 },
|
|
225
|
+
},
|
|
207
226
|
seo: {
|
|
208
227
|
defaultTitle: "LetItRip — Buy, Sell & Auction Collectibles | India's Collectibles Marketplace",
|
|
209
228
|
defaultDescription: "Buy, sell, and auction collectibles on India's marketplace for enthusiasts — action figures, trading cards, spinning tops, model kits, and more from verified sellers.",
|
|
@@ -509,7 +528,17 @@ export const siteSettingsSeedData = {
|
|
|
509
528
|
inApp: { enabled: true, readOnly: true },
|
|
510
529
|
email: { enabled: true, minPriority: "normal" },
|
|
511
530
|
whatsapp: { enabled: true, minPriority: "high", otpEnabled: true },
|
|
512
|
-
|
|
531
|
+
/*
|
|
532
|
+
* FALSE. There is no SMS sender anywhere in this codebase — nothing reads
|
|
533
|
+
* `notificationChannels.sms`, no Twilio SDK is installed, and the only
|
|
534
|
+
* "Twilio" reference is a parser for INBOUND webhook payloads.
|
|
535
|
+
*
|
|
536
|
+
* It was seeded `true`, which is worse than it sounds: the admin
|
|
537
|
+
* Notifications tab renders a channel as enabled, so anyone reading that
|
|
538
|
+
* screen would conclude SMS was live and being delivered. A channel that
|
|
539
|
+
* cannot send should not claim to be on.
|
|
540
|
+
*/
|
|
541
|
+
sms: { enabled: false, minPriority: "high" },
|
|
513
542
|
},
|
|
514
543
|
actionConfig: {},
|
|
515
544
|
navConfig: {},
|
package/dist/server.d.ts
CHANGED
|
@@ -253,13 +253,11 @@ export { FailedCheckoutRepository, failedCheckoutRepository } from "./features/c
|
|
|
253
253
|
export type { FailedCheckoutMeta, FailedPaymentMeta } from "./features/checkout/server";
|
|
254
254
|
export { EmailButton, EmailColumn, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailImage, EmailLink, EmailRow, } from "./features/email";
|
|
255
255
|
export type { EmailButtonProps, EmailColumnProps, EmailContainerProps, EmailDividerProps, EmailDocProps, EmailFooterProps, EmailHeaderProps, EmailImageProps, EmailLinkProps, EmailRowProps, EmailTone, } from "./features/email";
|
|
256
|
-
export { sendContactEmail } from "./features/contact/server";
|
|
257
256
|
export { sendEmail } from "./features/contact/server";
|
|
258
257
|
export { sendOrderConfirmationEmail } from "./features/contact/server";
|
|
259
|
-
export {
|
|
260
|
-
export {
|
|
261
|
-
export {
|
|
262
|
-
export type { OrderConfirmationEmailParams } from "./features/contact/server";
|
|
258
|
+
export type { OrderConfirmationEmailParams, SendEmailGuard } from "./features/contact/server";
|
|
259
|
+
export { authMailCooldownRepository, messageBudgetRepository, } from "./features/messaging/repository/message-budget.repository";
|
|
260
|
+
export type { MessageAudience, MessageChannel, SendDecision, SendGuardContext, } from "./_internal/shared/features/messaging/config";
|
|
263
261
|
export { adminGetEventById } from "./features/events/server";
|
|
264
262
|
export { adminGetEventEntries } from "./features/events/server";
|
|
265
263
|
export { adminGetEventStats } from "./features/events/server";
|
package/dist/server.js
CHANGED
|
@@ -723,24 +723,38 @@ export { FailedCheckoutRepository, failedCheckoutRepository } from "./features/c
|
|
|
723
723
|
// that render email-client-compatible HTML via renderToStaticMarkup. Use
|
|
724
724
|
// these instead of authoring raw <table>/<tr>/<td> in email templates.
|
|
725
725
|
export { EmailButton, EmailColumn, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailImage, EmailLink, EmailRow, } from "./features/email";
|
|
726
|
+
/*
|
|
727
|
+
* Four senders were removed from this barrel 2026-09 along with their
|
|
728
|
+
* implementations: `sendContactEmail` (the contact form is a record now, read
|
|
729
|
+
* from /admin/contact and the daily digest), `sendSiteSettingsChangedEmail`
|
|
730
|
+
* (adminAuditLog already records it, queryably), and
|
|
731
|
+
* `sendVerificationEmailWithLink` / `sendPasswordResetEmailWithLink` (dead
|
|
732
|
+
* since auth mail moved to the Firebase client SDK).
|
|
733
|
+
*/
|
|
726
734
|
// [SERVER-ONLY]-Server-only — uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
|
|
727
|
-
//
|
|
728
|
-
export { sendContactEmail } from "./features/contact/server";
|
|
729
|
-
// [SERVER-ONLY]-Server-only — uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
|
|
730
|
-
// sendEmail - Shared export for send email.
|
|
735
|
+
// sendEmail - Shared export for send email. Takes a REQUIRED guard context.
|
|
731
736
|
export { sendEmail } from "./features/contact/server";
|
|
732
737
|
// [SERVER-ONLY]-Server-only — uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
|
|
733
738
|
// sendOrderConfirmationEmail - Shared export for send order confirmation email.
|
|
734
739
|
export { sendOrderConfirmationEmail } from "./features/contact/server";
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
740
|
+
/*
|
|
741
|
+
* `guardSend` is deliberately NOT exported here.
|
|
742
|
+
*
|
|
743
|
+
* It lives in `_internal/`, and `audit-appkit-reexports` correctly refuses to
|
|
744
|
+
* let `_internal/` symbols leak through a public barrel. That is the right
|
|
745
|
+
* answer for this one on its merits too: every sender that needs the guard is
|
|
746
|
+
* inside appkit, and the two entry points that consumers DO call —
|
|
747
|
+
* `sendEmail(opts, guard)` and `sendNotification()` — apply it themselves. A
|
|
748
|
+
* consumer holding `guardSend` could only use it to build a fourth send path
|
|
749
|
+
* outside the two the audit knows how to check.
|
|
750
|
+
*
|
|
751
|
+
* `SendEmailGuard` (the context type) is exported alongside `sendEmail` from
|
|
752
|
+
* `./features/contact/server`, which is what a caller actually needs.
|
|
753
|
+
*/
|
|
754
|
+
// [SERVER-ONLY]-Firestore-backed cooldown for the Firebase-sent auth mails
|
|
755
|
+
// (password reset / verification), which never reach this app's server and so
|
|
756
|
+
// cannot be covered by guardSend.
|
|
757
|
+
export { authMailCooldownRepository, messageBudgetRepository, } from "./features/messaging/repository/message-budget.repository";
|
|
744
758
|
// [SERVER-ONLY]-Server-only — uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
|
|
745
759
|
// adminGetEventById - Shared export for admin get event by id.
|
|
746
760
|
export { adminGetEventById } from "./features/events/server";
|