@mohasinac/appkit 4.30.15 → 4.30.17
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/functions/https.js +17 -2
- package/dist/_internal/server/jobs/core/listingProcessor.js +5 -1
- package/dist/_internal/shared/types/action-error-reporter.d.ts +40 -0
- package/dist/_internal/shared/types/action-error-reporter.js +27 -0
- package/dist/_internal/shared/types/action-result.d.ts +10 -0
- package/dist/_internal/shared/types/action-result.js +26 -0
- package/dist/client/api/surface-error.d.ts +6 -4
- package/dist/client/api/surface-error.js +19 -8
- package/dist/contracts/realtime-session.d.ts +27 -0
- package/dist/contracts/realtime-session.js +118 -0
- package/dist/core/unit-of-work.js +21 -13
- package/dist/errors/base-error.js +14 -1
- package/dist/errors/database-error.js +11 -1
- package/dist/errors/describe-cause.d.ts +5 -0
- package/dist/errors/describe-cause.js +76 -0
- package/dist/errors/error-display-map.d.ts +32 -1
- package/dist/errors/error-display-map.js +46 -0
- package/dist/errors/error-mapping.d.ts +47 -7
- package/dist/errors/error-mapping.js +46 -5
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +4 -0
- package/dist/features/about/components/PublicProfileView.js +4 -4
- package/dist/features/account/components/AddressBook.js +2 -2
- package/dist/features/account/components/AddressesIndexListing.js +2 -2
- package/dist/features/addresses/repository/addresses.repository.d.ts +12 -1
- package/dist/features/addresses/repository/addresses.repository.js +16 -7
- package/dist/features/admin/components/AdminAnalyticsView.js +2 -2
- package/dist/features/admin/components/AdminCouponsView.js +2 -2
- package/dist/features/admin/components/AdminGuideHubView.js +2 -2
- package/dist/features/admin/components/AdminMediaView.js +2 -2
- package/dist/features/admin/components/AdminViewCards.js +2 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +1 -1
- package/dist/features/auctions/components/MarketplaceAuctionGrid.d.ts +1 -2
- package/dist/features/auctions/components/MarketplaceAuctionGrid.js +8 -7
- package/dist/features/auctions/components/PlaceBidFormClient.js +32 -15
- package/dist/features/auth/repository/user.repository.d.ts +22 -1
- package/dist/features/auth/repository/user.repository.js +31 -28
- package/dist/features/before-after/components/BeforeAfterGallery.js +2 -2
- package/dist/features/blog/components/BlogIndexListing.js +3 -3
- package/dist/features/blog/components/BlogListView.js +3 -3
- package/dist/features/blog/components/BlogPostView.js +2 -2
- package/dist/features/cart/repository/cart.repository.d.ts +15 -2
- package/dist/features/cart/repository/cart.repository.js +15 -13
- package/dist/features/categories/components/BundleCollage.js +2 -2
- package/dist/features/categories/components/CategoriesIndexListing.js +3 -3
- package/dist/features/categories/components/CategoryBundlesListing.js +2 -2
- package/dist/features/categories/components/CategoryGrid.js +2 -2
- package/dist/features/categories/components/CategoryProductsListing.js +2 -2
- package/dist/features/categories/components/CategoryStoresListing.js +2 -2
- package/dist/features/collections/components/CollectionCard.js +2 -2
- package/dist/features/events/components/EventRafflesSection.js +2 -2
- package/dist/features/events/components/EventsIndexListing.js +2 -2
- package/dist/features/events/components/EventsListView.js +3 -3
- package/dist/features/homepage/components/FeaturedBundlesSection.js +2 -2
- package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
- package/dist/features/homepage/components/HomepageSkeleton.js +1 -1
- package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
- package/dist/features/homepage/components/SocialFeedSection.js +2 -2
- package/dist/features/homepage/components/StatsCounterSection.js +1 -1
- package/dist/features/homepage/components/TrustBadges.js +2 -2
- package/dist/features/homepage/components/TrustFeaturesSection.js +1 -1
- package/dist/features/messages/hooks/useConversation.js +39 -15
- package/dist/features/messages/hooks/useConversations.js +36 -14
- package/dist/features/messages/repository/conversations.repository.d.ts +23 -0
- package/dist/features/messages/repository/conversations.repository.js +53 -0
- package/dist/features/orders/repository/orders.repository.d.ts +12 -1
- package/dist/features/orders/repository/orders.repository.js +14 -5
- package/dist/features/payments/repository/payout.repository.d.ts +19 -9
- package/dist/features/payments/repository/payout.repository.js +26 -20
- package/dist/features/pre-orders/components/PreOrdersIndexListing.js +2 -3
- package/dist/features/products/components/AuctionsIndexListing.js +2 -3
- package/dist/features/products/components/PrizeDrawCollage.js +2 -2
- package/dist/features/products/components/PrizeDrawsIndexListing.js +2 -3
- package/dist/features/products/components/PrizeDrawsSection.js +2 -2
- package/dist/features/products/components/ProductFilters.d.ts +19 -1
- package/dist/features/products/components/ProductFilters.js +11 -3
- package/dist/features/products/components/ProductGrid.js +2 -7
- package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
- package/dist/features/products/components/ProductsIndexListing.js +41 -28
- package/dist/features/products/components/RelatedProducts.js +2 -2
- package/dist/features/promotions/components/CouponsIndexListing.js +1 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +2 -2
- package/dist/features/reviews/components/ReviewsListingPanel.js +2 -2
- package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
- package/dist/features/reviews/repository/reviews.repository.js +16 -19
- package/dist/features/seller/components/SellerCouponsView.js +2 -2
- package/dist/features/seller/components/SellerDashboardView.js +3 -3
- package/dist/features/server-errors/schemas/firestore.d.ts +9 -0
- package/dist/features/stores/components/StoreAuctionsListing.js +2 -3
- package/dist/features/stores/components/StoreClassifiedsListing.js +2 -3
- package/dist/features/stores/components/StoreDigitalCodesListing.js +2 -3
- package/dist/features/stores/components/StoreGuideHubView.js +2 -2
- package/dist/features/stores/components/StoreLiveItemsListing.js +2 -3
- package/dist/features/stores/components/StorePreOrdersListing.js +2 -3
- package/dist/features/stores/components/StoreProductsListing.js +2 -2
- package/dist/features/stores/components/StoresIndexListing.js +3 -3
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +215 -18
- package/dist/next/api/routeHandler.js +27 -7
- package/dist/providers/db-firebase/base.repository.d.ts +12 -0
- package/dist/providers/db-firebase/base.repository.js +48 -20
- package/dist/react/hooks/usePendingFilters.d.ts +12 -2
- package/dist/react/hooks/usePendingFilters.js +2 -2
- package/dist/react/hooks/usePendingTable.d.ts +9 -2
- package/dist/react/hooks/useRealtimeEvent.js +22 -4
- package/dist/security/pii-schemas.d.ts +19 -1
- package/dist/security/pii-schemas.js +19 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +5 -0
- package/dist/styles.css +84 -20
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/tokens.css +7 -2
- package/dist/ui/components/Layout.d.ts +31 -18
- package/dist/ui/components/Layout.js +30 -19
- package/dist/ui/components/Layout.style.css +76 -17
- package/dist/ui/components/ListingLayout.d.ts +2 -0
- package/dist/ui/components/ListingLayout.js +16 -2
- package/dist/ui/components/StickyToolbar.js +3 -48
- package/dist/ui/components/toolbar-collapse.d.ts +44 -0
- package/dist/ui/components/toolbar-collapse.js +93 -0
- package/package.json +1 -1
|
@@ -174,6 +174,24 @@ const rawTesterChecklistItems = [
|
|
|
174
174
|
{ key: "browse-categories", label: "Browsing categories shows relevant products", href: "/categories" },
|
|
175
175
|
{ key: "search", label: "Search returns relevant results", href: "/search" },
|
|
176
176
|
{ key: "filters", label: "Search/listing filters (price, brand, condition) work correctly" },
|
|
177
|
+
{
|
|
178
|
+
key: "grid-one-card-per-row-on-phone",
|
|
179
|
+
label: "On a phone, a product listing shows exactly ONE card per row — not two narrow ones",
|
|
180
|
+
description: "BEFORE (until 2026-08-28): every listing put 2 cards per row at 375px, so each card was ~170px wide and its title/price squeezed. AFTER: 1 card per row on a phone, 2 on a tablet (~768px), 3–4 on a desktop. Check /products, /stores, /blog and an admin listing in card view. Resize slowly through 375 / 768 / 1024 / 1440 — the count should go 1, 2, 3, 4 and the cards should always fill the full width between the page margins, never leave a gap on the right.",
|
|
181
|
+
href: "/products",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
key: "grid-short-last-row-not-stretched",
|
|
185
|
+
label: "A listing whose last row is short does NOT stretch that card across the page",
|
|
186
|
+
description: "Find a listing showing e.g. 4 items while 3 fit per row (add filters until the count is not a multiple of the row size). The single card left over on the last row must be exactly as wide as the cards above it, sitting on the left with empty space to its right. If it stretches to full width, the grid was switched from `auto-fill` to `auto-fit` — report it, this is the specific bug the layout was rebuilt to fix.",
|
|
187
|
+
href: "/products",
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
key: "grid-follows-sidebar-not-viewport",
|
|
191
|
+
label: "Opening the desktop filter sidebar drops ONE column and keeps the cards the same size",
|
|
192
|
+
description: "On a wide desktop (~1440px) open /products and toggle the filter sidebar. The card count per row should drop by one while each card stays roughly the same width — the grid measures its own container, not the window. If the cards instead shrink to squeeze the same number in, report it.",
|
|
193
|
+
href: "/products",
|
|
194
|
+
},
|
|
177
195
|
{
|
|
178
196
|
key: "homepage-prize-draws-section",
|
|
179
197
|
label: "The homepage shows a \"Prize Draws\" strip with real cards — NOT an empty gap and NOT a \"Something went wrong\" block",
|
|
@@ -192,6 +210,18 @@ const rawTesterChecklistItems = [
|
|
|
192
210
|
description: "Press F12 → Console tab, then load the page. A missing translation key logs 'MISSING_MESSAGE: <key>' and renders the raw key text instead of a readable label. Report the exact key name if you see one.",
|
|
193
211
|
href: "/products",
|
|
194
212
|
},
|
|
213
|
+
{
|
|
214
|
+
key: "listing-toolbar-collapsed-on-mobile",
|
|
215
|
+
label: "On a phone, a listing opens with the search/filter toolbar COLLAPSED to a single \"Show Toolbar\" strip",
|
|
216
|
+
description: "BEFORE (until 2026-08-28): every listing opened with two stacked toolbar rows — search + Filter, then a scrolling sort/view chip row — roughly 100px of chrome above the first card. AFTER: a phone gets one thin \"Show Toolbar\" strip instead. Tap it: search, Filter and sort appear. Navigate to another listing and back — it should stay expanded, because you chose that. On a desktop (~1440px) the toolbar must still be open by default, exactly as before. Also confirm the toolbar is never simply GONE — there must always be a visible strip to tap.",
|
|
217
|
+
href: "/products",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
key: "listing-toolbar-forced-open-on-selection",
|
|
221
|
+
label: "Selecting rows on a phone force-opens the toolbar so the bulk actions are reachable",
|
|
222
|
+
description: "In an admin or seller listing on a phone, collapse the toolbar, then select a row (long-press or the checkbox). The toolbar must open by itself, because the bulk-action bar lives inside it — if it stays collapsed you can select rows but have no way to act on them. Report if the bulk bar is invisible or cut off.",
|
|
223
|
+
href: "/products",
|
|
224
|
+
},
|
|
195
225
|
{
|
|
196
226
|
key: "product-filter-status-labels",
|
|
197
227
|
label: "In an admin/seller listing's filter drawer, every Status option reads as words (Published / Draft / In Review / Archived) — never a raw key like \"filters.statusInReview\"",
|
|
@@ -220,52 +250,70 @@ const rawTesterChecklistItems = [
|
|
|
220
250
|
description: "Verify against the seeded fixtures: \"Test Collectible — Sold Out\" (standard, hidden until \"Show sold\" is on), \"Test Auction — Already Won\" (hidden until \"Show ended\" is on), \"Test Prize Draw — Already Closed\" (hidden until \"Show closed\" is on). All three should be genuinely absent by default, not just from an unrelated broken query.",
|
|
221
251
|
href: "/products",
|
|
222
252
|
},
|
|
253
|
+
{
|
|
254
|
+
key: "product-type-filter-lives-in-drawer",
|
|
255
|
+
label: "There is NO standalone \"TYPE\" chip row on /products — the listing-type filter is a \"Listing type\" checkbox section at the TOP of the Filters drawer",
|
|
256
|
+
description: "Changed 2026-08-28. BEFORE: a full-width band of pill chips (Standard / Auctions / Pre-Orders / …) sat between the Available-Sold-All tabs and the product grid, and clicking one filtered instantly. AFTER: that band is gone — the availability tabs sit directly above the grid — and the same options appear as checkboxes under \"Listing type\" when you press Filters. Report it if you can still see the old chip band anywhere.",
|
|
257
|
+
href: "/products",
|
|
258
|
+
},
|
|
223
259
|
{
|
|
224
260
|
key: "product-type-chips-cover-all-types",
|
|
225
|
-
label: "The
|
|
226
|
-
description: "
|
|
261
|
+
label: "The \"Listing type\" section in the /products Filters drawer lists ALL nine listing types — Standard, Auctions, Pre-Orders, Prize Draws, Classifieds, Digital Codes, Live Items, Art and Stickers",
|
|
262
|
+
description: "Open Filters and count the checkboxes under \"Listing type\": there should be nine, and none labelled \"Bundles\" (bundles are not a listing type). A type missing here is unreachable from the main catalogue even when items of that type exist.",
|
|
227
263
|
href: "/products",
|
|
228
264
|
},
|
|
229
265
|
{
|
|
230
266
|
key: "product-type-chips-multi-select",
|
|
231
|
-
label: "The
|
|
232
|
-
description: "Tick Auctions, then tick Pre-Orders WITHOUT unticking Auctions. Both must stay
|
|
267
|
+
label: "The \"Listing type\" boxes are CHECKBOXES, not radio buttons — ticking Auctions + Pre-Orders and pressing Apply shows both types in one grid",
|
|
268
|
+
description: "Tick Auctions, then tick Pre-Orders WITHOUT unticking Auctions, then press Apply. Both must stay ticked when you reopen the drawer, and the grid must contain items of both types. Untick both, Apply, and the grid returns to showing every type.",
|
|
233
269
|
href: "/products",
|
|
234
270
|
},
|
|
235
271
|
{
|
|
236
272
|
key: "product-type-chips-none-means-all",
|
|
237
|
-
label: "With NO type
|
|
273
|
+
label: "With NO \"Listing type\" box ticked, /products shows a mix of listing types — check the coloured type badges on the cards",
|
|
238
274
|
description: "\"Nothing ticked\" means \"every type\", so you should be able to spot at least two different badges (e.g. an \"Auction\" and a \"Digital Code\") within the first page without filtering.",
|
|
239
275
|
href: "/products",
|
|
240
276
|
},
|
|
277
|
+
{
|
|
278
|
+
key: "product-type-filter-counts-in-badge",
|
|
279
|
+
label: "Ticking a \"Listing type\" box and pressing Apply increases the number on the Filters button, and \"Reset all\" in the drawer clears the type selection again",
|
|
280
|
+
description: "Changed 2026-08-28 — the type filter used to sit outside the drawer and was invisible to both the badge and Reset. BEFORE: pick a type, badge stays where it was, Reset all leaves the type selection in place. AFTER: the badge goes up by one, and Reset all unticks every type box and repopulates the grid with all types.",
|
|
281
|
+
href: "/products",
|
|
282
|
+
},
|
|
241
283
|
{
|
|
242
284
|
key: "product-type-chip-drives-sort-options",
|
|
243
|
-
label: "
|
|
244
|
-
description: "
|
|
285
|
+
label: "Applying exactly ONE listing type changes the Sort dropdown to that type's own sorts — Auctions alone offers \"Ending Soon\", Pre-Orders alone offers \"Earliest Delivery\"",
|
|
286
|
+
description: "Filters → tick only Auctions → Apply, then open Sort: \"Ending Soon\" must be present. Swap to only Pre-Orders and Apply: \"Earliest Delivery\" must be present and \"Ending Soon\" gone. With two types applied only the sorts valid for both remain (Newest / Price / Name) — that is correct, not a bug.",
|
|
245
287
|
href: "/products",
|
|
246
288
|
},
|
|
247
289
|
{
|
|
248
290
|
key: "product-type-chip-sort-resets-no-empty-page",
|
|
249
291
|
label: "Choosing a type-specific sort and THEN changing the type selection never leaves an empty grid or an error",
|
|
250
|
-
description: "The important regression check.
|
|
292
|
+
description: "The important regression check. Apply Auctions, sort by \"Ending Soon\", then reopen Filters, untick Auctions, tick Standard, Apply. The grid must repopulate with products and the Sort dropdown must fall back to Newest. A blank page here means a stale sort leaked into the new query.",
|
|
251
293
|
href: "/products",
|
|
252
294
|
},
|
|
253
295
|
{
|
|
254
296
|
key: "product-type-toggles-follow-selection",
|
|
255
|
-
label: "The
|
|
256
|
-
description: "
|
|
297
|
+
label: "The middle availability tab is worded for the applied types — \"Ended\" for Auctions alone, \"Sold\" for Standard alone, \"Sold & Ended\" with nothing applied",
|
|
298
|
+
description: "Apply only Auctions and read the middle tab above the grid; then swap to only Standard and read it again. It must re-word itself, and switching to that tab must list the unavailable items of the applied type.",
|
|
257
299
|
href: "/products",
|
|
258
300
|
},
|
|
259
301
|
{
|
|
260
302
|
key: "product-type-chip-dedicated-page-link",
|
|
261
|
-
label: "Ticking exactly one type that has its own browse page shows a \"Full <type> filters\" link that lands on the right page",
|
|
262
|
-
description: "
|
|
303
|
+
label: "Ticking exactly one type that has its own browse page shows a \"Full <type> filters\" link, inside the drawer under the Listing type section, that lands on the right page",
|
|
304
|
+
description: "Changed 2026-08-28 — the link used to sit beside the old chip row. Open Filters, tick only Auctions: a \"Full Auctions filters →\" link appears directly under the Listing type checkboxes (before you press Apply) and opens /auctions. Repeat for Pre-Orders and Prize Draws. With two types ticked, or none, the link must NOT appear.",
|
|
263
305
|
href: "/products",
|
|
264
306
|
},
|
|
265
307
|
{
|
|
266
308
|
key: "product-type-selection-survives-reload",
|
|
267
|
-
label: "The
|
|
268
|
-
description: "
|
|
309
|
+
label: "The applied listing types are reflected in the URL and survive a reload and the browser Back button",
|
|
310
|
+
description: "Apply Auctions + Art, copy the URL, open it in a new tab: the grid must match and reopening Filters must show those two boxes already ticked. Then press Back and confirm the previous selection returns.",
|
|
311
|
+
href: "/products",
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
key: "product-type-pending-until-apply",
|
|
315
|
+
label: "Ticking a \"Listing type\" box does NOT change the grid until you press Apply — and closing the drawer with the X discards the change",
|
|
316
|
+
description: "Changed 2026-08-28 — the old chips filtered on every click. Tick Auctions and watch the grid behind the drawer: it must not change. Press Apply and it does. Reopen, tick Art, close the drawer with the X (not Apply), and reopen: Art must be unticked again, matching what is actually applied.",
|
|
269
317
|
href: "/products",
|
|
270
318
|
},
|
|
271
319
|
{
|
|
@@ -282,8 +330,8 @@ const rawTesterChecklistItems = [
|
|
|
282
330
|
},
|
|
283
331
|
{
|
|
284
332
|
key: "pre-orders-reachable-from-products",
|
|
285
|
-
label: "Pre-orders are reachable from /products — tick
|
|
286
|
-
description: "This was the original report: pre-orders (and auctions, prize draws, art, stickers) had no
|
|
333
|
+
label: "Pre-orders are reachable from /products — Filters → Listing type → tick Pre-Orders → Apply, and confirm real pre-order items appear with a \"Pre-Order\" badge",
|
|
334
|
+
description: "This was the original report: pre-orders (and auctions, prize draws, art, stickers) had no entry in the type filter at all, so they could only be found via their own dedicated pages.",
|
|
287
335
|
href: "/products",
|
|
288
336
|
},
|
|
289
337
|
{
|
|
@@ -294,7 +342,8 @@ const rawTesterChecklistItems = [
|
|
|
294
342
|
},
|
|
295
343
|
{
|
|
296
344
|
key: "art-stickers-type-chips-narrow",
|
|
297
|
-
label: "On /art, the \"
|
|
345
|
+
label: "On /art, the Filters drawer's \"Listing type\" section offers exactly two boxes — Art and Stickers — and each one alone narrows the grid to that type without ever returning zero rows",
|
|
346
|
+
description: "This page spans only two of the nine listing types, so its Listing type section must show only those two. Tick Art → Apply, then Stickers → Apply, then untick both → Apply; all three states must return rows.",
|
|
298
347
|
href: "/art",
|
|
299
348
|
},
|
|
300
349
|
{
|
|
@@ -668,6 +717,18 @@ const rawTesterChecklistItems = [
|
|
|
668
717
|
label: "A seller's per-listing \"Minimum Bid Increment\" can require MORE than the admin tier, but can never let a bid undercut the tier",
|
|
669
718
|
description: "Set a listing's \"Minimum Bid Increment\" below the current tier's value (e.g. 1) and confirm the effective minimum bid still enforces the tier, not the smaller override. Then set it above the tier (e.g. 500 when the tier is 100) and confirm the higher override is now enforced instead.",
|
|
670
719
|
},
|
|
720
|
+
{
|
|
721
|
+
key: "bid-succeeds-and-outbids-previous-winner",
|
|
722
|
+
label: "Placing a valid bid actually records it — the current bid rises and the previous high bid flips to \"outbid\"",
|
|
723
|
+
description: "Fixed 2026-08-28. BEFORE: every bid failed and the modal showed the raw text \"Batch write failed: Cannot find module '../providers/db-firebase' Require stack: - /var/task/.next/server/chunks/ssr/...\" in the amount field. AFTER: the bid is accepted, \"Current bid\" rises by at least the minimum increment, and the previous winner's row in Bid History reads \"outbid\". Note the failure returned a normally-rendered modal and an HTTP 200, so \"the page loaded\" and \"no error in the network tab\" both prove nothing — read the actual numbers. Root cause: a relative-path runtime require inside appkit that only breaks once bundled into a Lambda chunk.",
|
|
724
|
+
href: "/auctions/auction-tester-sandbox-cycle-1",
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
key: "server-error-copy-is-never-raw",
|
|
728
|
+
label: "A server failure shows plain-English copy — never a file path, stack trace or \"Cannot find module\"",
|
|
729
|
+
description: "Fixed 2026-08-28. BEFORE: any 500 printed its own internal message to the user, including absolute server paths under /var/task/. AFTER: the user sees \"A database error occurred. Please try again\" (or \"Something went wrong. Please try again.\"), while the FULL original text plus its cause chain appears in Admin → Maintenance → Server errors. Check both halves: generic in the browser, specific in the admin list. Seeing the raw text in either the bid modal or any toast is a regression.",
|
|
730
|
+
href: "/auctions/auction-tester-sandbox-cycle-1",
|
|
731
|
+
},
|
|
671
732
|
{
|
|
672
733
|
key: "bid-increment-live-tier-change",
|
|
673
734
|
label: "The displayed \"min increment\" on an open auction updates live (without a page refresh) if another bidder's bid pushes the current bid across a tier boundary",
|
|
@@ -1719,7 +1780,18 @@ const rawTesterChecklistItems = [
|
|
|
1719
1780
|
cases: [
|
|
1720
1781
|
{ key: "start-conversation", label: "Starting a conversation with a seller works" },
|
|
1721
1782
|
{ key: "receive-reply", label: "Receiving and reading a seller's reply works" },
|
|
1722
|
-
{
|
|
1783
|
+
{
|
|
1784
|
+
key: "message-realtime-update",
|
|
1785
|
+
label: "A new message appears in the RECIPIENT's thread without a manual page refresh (realtime ping-channel)",
|
|
1786
|
+
description: "Fixed 2026-08-28. BEFORE: only the SENDER saw their own message, because the send handler calls refetch() directly — the recipient saw nothing until they reloaded. Two stacked faults: the hooks subscribed without ever signing in to the realtime database, AND the `conversationIds` claim the security rule requires had no issuer anywhere (the token route only sent `chatIds`, from an unrelated collection). Both errors were swallowed, one into a literal `// ignore`. AFTER: open the same conversation as buyer in one browser and seller in another, send from one, and the other updates on its own. Test it from BOTH directions — testing only the sender's own window is what let this pass before.",
|
|
1787
|
+
href: "/user/messages",
|
|
1788
|
+
},
|
|
1789
|
+
{
|
|
1790
|
+
key: "message-live-updates-survive-bulk-job",
|
|
1791
|
+
label: "Running a bulk admin action while a message thread is open does not kill that thread's live updates",
|
|
1792
|
+
description: "Fixed 2026-08-28. BEFORE: the realtime app is a single shared connection, and finishing a bulk job signed it out globally — so an open chat or message thread silently stopped updating, and vice versa (opening a chat killed an in-flight bulk job's progress). AFTER: open a message thread, run any bulk action from an admin listing until it completes, then send a message from the other account — the thread must still update live without a reload.",
|
|
1793
|
+
href: "/user/messages",
|
|
1794
|
+
},
|
|
1723
1795
|
{ key: "messages-list-thread", label: "The messages list and individual thread view both load correctly", href: "/user/messages" },
|
|
1724
1796
|
],
|
|
1725
1797
|
},
|
|
@@ -2338,6 +2410,131 @@ const rawTesterChecklistItems = [
|
|
|
2338
2410
|
{ key: "tabs-mobile-dropdown", label: "Tabs on category/brand/product/event detail pages collapse into a colored dropdown on mobile once there are more than 5 tabs" },
|
|
2339
2411
|
],
|
|
2340
2412
|
},
|
|
2413
|
+
{
|
|
2414
|
+
pageKey: "search",
|
|
2415
|
+
pageLabel: "Search",
|
|
2416
|
+
href: "/products",
|
|
2417
|
+
cases: [
|
|
2418
|
+
{
|
|
2419
|
+
key: "search-typeahead-differs",
|
|
2420
|
+
label: "Typing \"dranzer\" and then \"dragoon\" in the header search gives DIFFERENT suggestions",
|
|
2421
|
+
description: "WAS: the same 5 newest rows appeared for every keystroke, because no search term reached the query at all. If both terms give an identical list, the term is still being ignored.",
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
key: "search-typeahead-no-drafts",
|
|
2425
|
+
label: "Header search suggestions contain no draft or archived listings",
|
|
2426
|
+
description: "WAS: the suggestion query applied no status filter, so unpublished drafts were visible to the public.",
|
|
2427
|
+
},
|
|
2428
|
+
{
|
|
2429
|
+
key: "search-prefix-match",
|
|
2430
|
+
label: "Searching \"dran\" finds Dranzer products on /products",
|
|
2431
|
+
description: "WAS: no match at all - only whole words matched, so a partial term found nothing.",
|
|
2432
|
+
href: "/products",
|
|
2433
|
+
},
|
|
2434
|
+
{
|
|
2435
|
+
key: "search-both-words-required",
|
|
2436
|
+
label: "Searching \"red dranzer\" returns only items matching BOTH words, not either one",
|
|
2437
|
+
description: "WAS: array-contains-any made multi-word search an OR, so adding a second word made the list LONGER instead of narrower. Check the count drops versus \"dranzer\" alone.",
|
|
2438
|
+
href: "/products",
|
|
2439
|
+
},
|
|
2440
|
+
{
|
|
2441
|
+
key: "search-plus-category-filter",
|
|
2442
|
+
label: "A search term AND a category filter apply together on /products",
|
|
2443
|
+
description: "WAS: an empty if-body dropped BOTH, so the page ignored the search and the filter simultaneously and showed everything. Apply one, note the count, then add the other and confirm it narrows again.",
|
|
2444
|
+
href: "/products",
|
|
2445
|
+
},
|
|
2446
|
+
{
|
|
2447
|
+
key: "search-keeps-sort",
|
|
2448
|
+
label: "Changing the sort while a search term is active re-orders the results and keeps the term",
|
|
2449
|
+
description: "WAS: the sort was silently discarded whenever a term was present, so the dropdown said \"Oldest\" over unsorted rows.",
|
|
2450
|
+
href: "/products",
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
key: "search-keeps-facets",
|
|
2454
|
+
label: "On /products, searching keeps the price, tag and availability facets working",
|
|
2455
|
+
description: "WAS: the old case-insensitive operator threw inside the query builder, and because exceptions were suppressed the throw aborted every clause AFTER it - so one search term silently disabled the rest of the filter bar.",
|
|
2456
|
+
href: "/products",
|
|
2457
|
+
},
|
|
2458
|
+
{
|
|
2459
|
+
key: "search-case-and-accents",
|
|
2460
|
+
label: "\"POKEMON\" with an accent, without one, and capitalised all return the same results",
|
|
2461
|
+
description: "WAS: matching was case- and accent-sensitive, so only the exact stored spelling matched.",
|
|
2462
|
+
href: "/products",
|
|
2463
|
+
},
|
|
2464
|
+
{
|
|
2465
|
+
key: "search-single-char-narrows",
|
|
2466
|
+
label: "A single-character query narrows the list instead of showing everything",
|
|
2467
|
+
description: "WAS: a 1-char term was discarded as too short and the unfiltered collection came back - indistinguishable from a search that matched everything.",
|
|
2468
|
+
href: "/products",
|
|
2469
|
+
},
|
|
2470
|
+
{
|
|
2471
|
+
key: "search-faqs",
|
|
2472
|
+
label: "Searching on /faqs returns matching questions",
|
|
2473
|
+
description: "WAS: this is the report that started the whole migration - 63 FAQs existed, none had search tokens, so every query returned empty.",
|
|
2474
|
+
href: "/faqs",
|
|
2475
|
+
},
|
|
2476
|
+
{
|
|
2477
|
+
key: "search-faq-category-page",
|
|
2478
|
+
label: "Opening a FAQ category page renders its questions",
|
|
2479
|
+
description: "WAS: a missing index threw FAILED_PRECONDITION, which was swallowed, leaving a blank page with no error.",
|
|
2480
|
+
href: "/faqs",
|
|
2481
|
+
},
|
|
2482
|
+
{
|
|
2483
|
+
key: "search-scams-listing",
|
|
2484
|
+
label: "/scams lists the verified scammer profiles",
|
|
2485
|
+
description: "WAS: \"No verified scammers yet\" always, because the query needed an index that did not exist.",
|
|
2486
|
+
href: "/scams",
|
|
2487
|
+
},
|
|
2488
|
+
{
|
|
2489
|
+
key: "search-scam-partial-and-upi",
|
|
2490
|
+
label: "On /admin/scammers, searching \"Vikram\" finds \"Vikram Mehta\", and searching a UPI id also finds its profile",
|
|
2491
|
+
description: "WAS: the query matched a WHOLE array element, so \"Vikram\" found nothing against a list holding \"Vikram M\" and \"Vikram Mehta\" - and phones and UPI ids were never searched at all despite the box promising them.",
|
|
2492
|
+
href: "/admin/scammers",
|
|
2493
|
+
},
|
|
2494
|
+
{
|
|
2495
|
+
key: "search-store-event-blog-review",
|
|
2496
|
+
label: "Store, event, blog and review searches each return matches",
|
|
2497
|
+
description: "WAS: never implemented for any of the four - the box existed and the endpoint ignored the term.",
|
|
2498
|
+
href: "/blog",
|
|
2499
|
+
},
|
|
2500
|
+
{
|
|
2501
|
+
key: "search-admin-exact-match",
|
|
2502
|
+
label: "On /admin/reviews a FULL reviewer name matches, a partial one matches nothing, and the box says \"exact match\"",
|
|
2503
|
+
description: "This asserts a DELIBERATE decision, not a bug: the field is encrypted, and ciphertext has no usable prefix, so it is resolved through a blind index that matches exactly. What was wrong was the placeholder - it read \"Search reviews, products, or seller names\" and matched none of those three.",
|
|
2504
|
+
href: "/admin/reviews",
|
|
2505
|
+
},
|
|
2506
|
+
{
|
|
2507
|
+
key: "search-admin-degraded-sort",
|
|
2508
|
+
label: "Searching on /admin/payouts shows a notice that results are not sorted while searching",
|
|
2509
|
+
description: "Preserving the sort would need 14 more composite indexes to order a result an exact email match bounds at one row, so the sort is dropped on purpose. WAS: dropped SILENTLY - the sort dropdown kept displaying \"Oldest\" over unsorted rows.",
|
|
2510
|
+
href: "/admin/payouts",
|
|
2511
|
+
},
|
|
2512
|
+
{
|
|
2513
|
+
key: "search-admin-team-filter-chip",
|
|
2514
|
+
label: "On /admin/team, applying any filter chip still returns employees",
|
|
2515
|
+
description: "WAS: the filter string was CONCATENATED rather than joined, producing one malformed clause that matched nothing - so the team list silently emptied the moment anyone filtered it.",
|
|
2516
|
+
href: "/admin/team",
|
|
2517
|
+
},
|
|
2518
|
+
{
|
|
2519
|
+
key: "search-no-empty-toolbar-gap",
|
|
2520
|
+
label: "A listing page whose search box was removed shows no empty gap in its toolbar",
|
|
2521
|
+
description: "31 boxes were removed because their endpoints ignored the term. The toolbar should close up, not leave a hole where the input was.",
|
|
2522
|
+
href: "/admin/coupons",
|
|
2523
|
+
},
|
|
2524
|
+
{
|
|
2525
|
+
key: "search-nonsense-term-returns-nothing",
|
|
2526
|
+
label: "Searching \"zzzznope\" anywhere returns ZERO results",
|
|
2527
|
+
description: "The single most important case here. Every bug above returned HTTP 200 with plausible-looking rows, so a real term matching something proves nothing - only a nonsense term returning nothing distinguishes \"filtering\" from \"returning everything\". Try it on /products, /faqs, /blog and /admin/scammers.",
|
|
2528
|
+
href: "/products",
|
|
2529
|
+
},
|
|
2530
|
+
{
|
|
2531
|
+
key: "search-finds-older-records",
|
|
2532
|
+
label: "An OLDER listing - one that existed before this feature shipped - is findable by name",
|
|
2533
|
+
description: "Write this one carefully. Every other case here passes on freshly seeded data while real production documents stay invisible, because search tokens are only written when a record is saved. Pick a listing that has NOT been edited recently and confirm it is findable; if it is not, the backfill did not cover it.",
|
|
2534
|
+
href: "/products",
|
|
2535
|
+
},
|
|
2536
|
+
],
|
|
2537
|
+
},
|
|
2341
2538
|
]),
|
|
2342
2539
|
...group("community-support", "Community & Support", [
|
|
2343
2540
|
{
|
|
@@ -32,6 +32,7 @@ import { NextResponse } from "next/server.js";
|
|
|
32
32
|
import { getProviders } from "../../contracts";
|
|
33
33
|
import { isEffectiveAdminUser } from "../../features/auth/role-predicates";
|
|
34
34
|
import { mapToHttpError, HTTP_ERROR_CODES } from "../../errors/error-mapping";
|
|
35
|
+
import { describeCauseChain } from "../../errors/describe-cause";
|
|
35
36
|
import { buildErrorEnvelope, toApiIssues } from "../../errors/error-envelope";
|
|
36
37
|
import { serverErrorsRepository } from "../../features/server-errors/repository/server-errors.repository";
|
|
37
38
|
import { userRepository } from "../../repositories";
|
|
@@ -93,10 +94,17 @@ const LOG_AS_INCIDENT_CODES = new Set([
|
|
|
93
94
|
HTTP_ERROR_CODES.PAYMENT_ROLLBACK_ATTEMPTED,
|
|
94
95
|
HTTP_ERROR_CODES.PAYMENT_ROLLBACK_FAILED,
|
|
95
96
|
]);
|
|
96
|
-
function shouldPersist(
|
|
97
|
-
if (status >= 500)
|
|
97
|
+
function shouldPersist(mapped) {
|
|
98
|
+
if (mapped.status >= 500)
|
|
98
99
|
return true;
|
|
99
|
-
|
|
100
|
+
// Firestore itself refused. `permission-denied` (expired service-account
|
|
101
|
+
// key) and `resource-exhausted` (quota blown) map to 403/429, which sit
|
|
102
|
+
// below the 5xx threshold — so these were previously recorded nowhere.
|
|
103
|
+
// Keyed on provenance rather than code, because our own RBAC guard and rate
|
|
104
|
+
// limiter emit those same codes in normal operation.
|
|
105
|
+
if (mapped.infrastructure)
|
|
106
|
+
return true;
|
|
107
|
+
return LOG_AS_INCIDENT_CODES.has(mapped.code);
|
|
100
108
|
}
|
|
101
109
|
/**
|
|
102
110
|
* Resolve the body schema for a createRouteHandler call. Accepts either a
|
|
@@ -282,16 +290,28 @@ export function createRouteHandler(options) {
|
|
|
282
290
|
const mapped = mapToHttpError(err, {
|
|
283
291
|
isProduction: process.env.NODE_ENV === "production",
|
|
284
292
|
});
|
|
285
|
-
// -- Persisted log (5xx + selected 4xx)
|
|
286
|
-
|
|
287
|
-
|
|
293
|
+
// -- Persisted log (5xx + infrastructure + selected 4xx) ---------------
|
|
294
|
+
// AWAITED, not fire-and-forget. `void record(...)` raced the response:
|
|
295
|
+
// the lambda can be frozen or reclaimed the moment we return, and
|
|
296
|
+
// `record()` swallows its own failures, so a lost write left no trace
|
|
297
|
+
// anywhere — indistinguishable from "the error never happened".
|
|
298
|
+
// `record()` is documented never to throw, so awaiting it cannot turn a
|
|
299
|
+
// logging failure into a request failure; the cost is one Firestore
|
|
300
|
+
// round-trip on an already-failed request.
|
|
301
|
+
if (shouldPersist(mapped)) {
|
|
302
|
+
await serverErrorsRepository().record({
|
|
288
303
|
source: "vercel",
|
|
289
304
|
route: new URL(request.url).pathname,
|
|
290
305
|
method: request.method,
|
|
291
306
|
...(user?.uid ? { userId: user.uid } : {}),
|
|
292
307
|
code: mapped.code,
|
|
293
|
-
message
|
|
308
|
+
// internalMessage ?? message — `mapped.message` is scrubbed to a
|
|
309
|
+
// generic string for 5xx in production so it is safe to send to a
|
|
310
|
+
// browser. The store must get the real one, or the scrub would have
|
|
311
|
+
// traded a UI leak for blindness.
|
|
312
|
+
message: mapped.internalMessage ?? mapped.message,
|
|
294
313
|
stack: err instanceof Error ? err.stack : undefined,
|
|
314
|
+
cause: describeCauseChain(err),
|
|
295
315
|
requestId,
|
|
296
316
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
|
297
317
|
});
|
|
@@ -62,6 +62,18 @@ export declare abstract class BaseRepository<T extends DocumentData> {
|
|
|
62
62
|
update(id: string, data: Partial<T>): Promise<T>;
|
|
63
63
|
delete(id: string): Promise<void>;
|
|
64
64
|
exists(id: string): Promise<boolean>;
|
|
65
|
+
/**
|
|
66
|
+
* Server-side aggregation — never a full read.
|
|
67
|
+
*
|
|
68
|
+
* This used to be `getCollection().get()` then `.size`, which downloads every
|
|
69
|
+
* document in the collection to learn how many there are. On `products` or
|
|
70
|
+
* `orders` that is the entire collection billed as reads, and the payload is
|
|
71
|
+
* discarded immediately. Straight Rule #6 violation, and it scales with the
|
|
72
|
+
* data rather than staying flat.
|
|
73
|
+
*
|
|
74
|
+
* `count()` is a Firestore aggregation query: the server returns a number,
|
|
75
|
+
* billed at one read per 1000 documents matched rather than one per document.
|
|
76
|
+
*/
|
|
65
77
|
count(): Promise<number>;
|
|
66
78
|
protected sieveQuery<TResult extends DocumentData = T>(model: SieveModel, fields: FirebaseSieveFields, options?: FirebaseSieveOptions & {
|
|
67
79
|
baseQuery?: CollectionReference | Query;
|
|
@@ -5,6 +5,17 @@ import { applySieveToFirestore, countSieveMatches, } from "./sieve";
|
|
|
5
5
|
import { deserializeTimestamps, prepareForFirestore } from "./helpers";
|
|
6
6
|
import { getAdminDb } from "./admin";
|
|
7
7
|
import { encryptPiiFields, piiIndicesFor } from "../../security/pii-encrypt";
|
|
8
|
+
/**
|
|
9
|
+
* The underlying failure's message, for interpolation into a DatabaseError.
|
|
10
|
+
*
|
|
11
|
+
* Every wrap below used to read `"Failed to update document: abc123"` and
|
|
12
|
+
* nothing else, so the persisted serverErrors row — whose `message` is what the
|
|
13
|
+
* admin list renders — said only which method failed, never why. `cause` now
|
|
14
|
+
* carries the stack, but a human scanning the list needs the reason on the row.
|
|
15
|
+
*/
|
|
16
|
+
function detailOf(error) {
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
8
19
|
export class BaseRepository {
|
|
9
20
|
constructor(collectionName) {
|
|
10
21
|
/**
|
|
@@ -60,7 +71,7 @@ export class BaseRepository {
|
|
|
60
71
|
}
|
|
61
72
|
catch (error) {
|
|
62
73
|
void normalizeError(error);
|
|
63
|
-
throw new DatabaseError(`Failed to find document by ID: ${id}`, error);
|
|
74
|
+
throw new DatabaseError(`Failed to find document by ID: ${id}: ${detailOf(error)}`, error);
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
async findByIdOrFail(id) {
|
|
@@ -79,7 +90,7 @@ export class BaseRepository {
|
|
|
79
90
|
}
|
|
80
91
|
catch (error) {
|
|
81
92
|
void normalizeError(error);
|
|
82
|
-
throw new DatabaseError(`Failed to find documents by ${field}`, error);
|
|
93
|
+
throw new DatabaseError(`Failed to find documents by ${field}: ${detailOf(error)}`, error);
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
96
|
async findOneBy(field, value) {
|
|
@@ -95,7 +106,7 @@ export class BaseRepository {
|
|
|
95
106
|
}
|
|
96
107
|
catch (error) {
|
|
97
108
|
void normalizeError(error);
|
|
98
|
-
throw new DatabaseError(`Failed to find document by ${field}`, error);
|
|
109
|
+
throw new DatabaseError(`Failed to find document by ${field}: ${detailOf(error)}`, error);
|
|
99
110
|
}
|
|
100
111
|
}
|
|
101
112
|
async findAll(limit) {
|
|
@@ -109,7 +120,7 @@ export class BaseRepository {
|
|
|
109
120
|
}
|
|
110
121
|
catch (error) {
|
|
111
122
|
void normalizeError(error);
|
|
112
|
-
throw new DatabaseError(
|
|
123
|
+
throw new DatabaseError(`Failed to fetch all documents: ${detailOf(error)}`, error);
|
|
113
124
|
}
|
|
114
125
|
}
|
|
115
126
|
/**
|
|
@@ -158,7 +169,7 @@ export class BaseRepository {
|
|
|
158
169
|
}
|
|
159
170
|
catch (error) {
|
|
160
171
|
void normalizeError(error);
|
|
161
|
-
throw new DatabaseError(
|
|
172
|
+
throw new DatabaseError(`Failed to create document: ${detailOf(error)}`, error);
|
|
162
173
|
}
|
|
163
174
|
}
|
|
164
175
|
async createWithId(id, data) {
|
|
@@ -169,20 +180,20 @@ export class BaseRepository {
|
|
|
169
180
|
createdAt: now,
|
|
170
181
|
updatedAt: now,
|
|
171
182
|
});
|
|
172
|
-
|
|
183
|
+
// No success-path logging. This emitted one debug + one info line for
|
|
184
|
+
// EVERY document written, against Rule #6's ~4 KB/s log budget, and the
|
|
185
|
+
// failure path below already carries everything the success path did.
|
|
173
186
|
await this.getCollection().doc(id).set(cleanData);
|
|
174
|
-
serverLogger.info(`Document created successfully: ${id}`);
|
|
175
187
|
const doc = await this.getCollection().doc(id).get();
|
|
176
188
|
return this.mapDoc(doc);
|
|
177
189
|
}
|
|
178
190
|
catch (error) {
|
|
179
191
|
void normalizeError(error);
|
|
180
|
-
serverLogger.error
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
});
|
|
185
|
-
throw new DatabaseError(`Failed to create document with ID: ${id}`, error);
|
|
192
|
+
// No serverLogger.error here either — this method was the only one of the
|
|
193
|
+
// ten wraps that logged AND rethrew, so its failures were recorded twice
|
|
194
|
+
// (once here, once by createRouteHandler), the first copy strictly less
|
|
195
|
+
// informative. The DatabaseError below now carries the same detail.
|
|
196
|
+
throw new DatabaseError(`Failed to create document with ID: ${id}: ${detailOf(error)}`, error);
|
|
186
197
|
}
|
|
187
198
|
}
|
|
188
199
|
async update(id, data) {
|
|
@@ -196,7 +207,7 @@ export class BaseRepository {
|
|
|
196
207
|
}
|
|
197
208
|
catch (error) {
|
|
198
209
|
void normalizeError(error);
|
|
199
|
-
throw new DatabaseError(`Failed to update document: ${id}`, error);
|
|
210
|
+
throw new DatabaseError(`Failed to update document: ${id}: ${detailOf(error)}`, error);
|
|
200
211
|
}
|
|
201
212
|
}
|
|
202
213
|
async delete(id) {
|
|
@@ -205,7 +216,7 @@ export class BaseRepository {
|
|
|
205
216
|
}
|
|
206
217
|
catch (error) {
|
|
207
218
|
void normalizeError(error);
|
|
208
|
-
throw new DatabaseError(`Failed to delete document: ${id}`, error);
|
|
219
|
+
throw new DatabaseError(`Failed to delete document: ${id}: ${detailOf(error)}`, error);
|
|
209
220
|
}
|
|
210
221
|
}
|
|
211
222
|
async exists(id) {
|
|
@@ -215,17 +226,29 @@ export class BaseRepository {
|
|
|
215
226
|
}
|
|
216
227
|
catch (error) {
|
|
217
228
|
void normalizeError(error);
|
|
218
|
-
throw new DatabaseError(`Failed to check document existence: ${id}`, error);
|
|
229
|
+
throw new DatabaseError(`Failed to check document existence: ${id}: ${detailOf(error)}`, error);
|
|
219
230
|
}
|
|
220
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Server-side aggregation — never a full read.
|
|
234
|
+
*
|
|
235
|
+
* This used to be `getCollection().get()` then `.size`, which downloads every
|
|
236
|
+
* document in the collection to learn how many there are. On `products` or
|
|
237
|
+
* `orders` that is the entire collection billed as reads, and the payload is
|
|
238
|
+
* discarded immediately. Straight Rule #6 violation, and it scales with the
|
|
239
|
+
* data rather than staying flat.
|
|
240
|
+
*
|
|
241
|
+
* `count()` is a Firestore aggregation query: the server returns a number,
|
|
242
|
+
* billed at one read per 1000 documents matched rather than one per document.
|
|
243
|
+
*/
|
|
221
244
|
async count() {
|
|
222
245
|
try {
|
|
223
|
-
const snapshot = await this.getCollection().get();
|
|
224
|
-
return snapshot.
|
|
246
|
+
const snapshot = await this.getCollection().count().get();
|
|
247
|
+
return snapshot.data().count;
|
|
225
248
|
}
|
|
226
249
|
catch (error) {
|
|
227
250
|
void normalizeError(error);
|
|
228
|
-
throw new DatabaseError(
|
|
251
|
+
throw new DatabaseError(`Failed to count documents: ${detailOf(error)}`, error);
|
|
229
252
|
}
|
|
230
253
|
}
|
|
231
254
|
async sieveQuery(model, fields, options) {
|
|
@@ -286,7 +309,12 @@ export class BaseRepository {
|
|
|
286
309
|
}
|
|
287
310
|
catch (error) {
|
|
288
311
|
void normalizeError(error);
|
|
289
|
-
|
|
312
|
+
// ERROR, not warn, and WITH the error object. A count failing here is
|
|
313
|
+
// almost always a missing composite index — a permanent, silent
|
|
314
|
+
// degradation of every facet on the listing page, not a transient
|
|
315
|
+
// blip. Logging it at warn with no error attached meant the actual
|
|
316
|
+
// FAILED_PRECONDITION (which names the index to create) was discarded.
|
|
317
|
+
serverLogger.error(`facetCounts: count failed for ${this.collection}.${field}==${value}; facet will stay visible`, { collection: this.collection, field, value, error });
|
|
290
318
|
return [value, undefined];
|
|
291
319
|
}
|
|
292
320
|
}));
|
|
@@ -19,8 +19,18 @@ export interface UsePendingFiltersReturn {
|
|
|
19
19
|
appliedCount: number;
|
|
20
20
|
/** Update one key in pending state (does NOT write to URL) */
|
|
21
21
|
set: (key: string, values: string[]) => void;
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Write all pending keys to the URL (resets page to 1).
|
|
24
|
+
*
|
|
25
|
+
* `extras` piggy-backs non-filter keys onto the SAME `router.replace` —
|
|
26
|
+
* the escape hatch `clearAll` already has, for the same reason. A caller
|
|
27
|
+
* that needs to blank `sort` alongside a filter change (a sort valid for
|
|
28
|
+
* one selection can be FAILED_PRECONDITION for the next) must not issue a
|
|
29
|
+
* second `set`: it would read stale searchParams and overwrite this update
|
|
30
|
+
* (Root Cause #13). Filter keys are written last, so `extras` can never
|
|
31
|
+
* shadow one.
|
|
32
|
+
*/
|
|
33
|
+
apply: (extras?: Record<string, string>) => void;
|
|
24
34
|
/** Discard pending, revert to applied (URL) state */
|
|
25
35
|
reset: () => void;
|
|
26
36
|
/** Clear all keys in both pending state and the URL */
|
|
@@ -47,8 +47,8 @@ export function usePendingFilters({ table, keys, }) {
|
|
|
47
47
|
const set = useCallback((key, values) => {
|
|
48
48
|
setPending((prev) => ({ ...prev, [key]: values }));
|
|
49
49
|
}, []);
|
|
50
|
-
const apply = useCallback(() => {
|
|
51
|
-
const updates = { page: "1" };
|
|
50
|
+
const apply = useCallback((extras) => {
|
|
51
|
+
const updates = { page: "1", ...extras };
|
|
52
52
|
for (const k of keys) {
|
|
53
53
|
updates[k] = (pending[k] ?? []).join(",");
|
|
54
54
|
}
|
|
@@ -13,8 +13,15 @@ export interface UsePendingTableReturn {
|
|
|
13
13
|
pendingTable: PendingTable;
|
|
14
14
|
/** Count of applied (URL) filter values — use for the filter badge */
|
|
15
15
|
filterActiveCount: number;
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Commit all pending changes to the URL (resets page to 1).
|
|
18
|
+
*
|
|
19
|
+
* Pass `extras` to write non-filter keys in the SAME `router.replace` —
|
|
20
|
+
* e.g. blanking `sort` when the applied filters invalidate it. A follow-up
|
|
21
|
+
* `table.set` would read stale searchParams and clobber this one
|
|
22
|
+
* (Root Cause #13).
|
|
23
|
+
*/
|
|
24
|
+
onFilterApply: (extras?: Record<string, string>) => void;
|
|
18
25
|
/** Clear all filter keys from pending state and URL */
|
|
19
26
|
onFilterClear: () => void;
|
|
20
27
|
/**
|