@mohasinac/appkit 4.30.15 → 4.30.16
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/features/addresses/repository/addresses.repository.d.ts +12 -1
- package/dist/features/addresses/repository/addresses.repository.js +16 -7
- package/dist/features/auth/repository/user.repository.d.ts +22 -1
- package/dist/features/auth/repository/user.repository.js +31 -28
- package/dist/features/cart/repository/cart.repository.d.ts +15 -2
- package/dist/features/cart/repository/cart.repository.js +15 -13
- 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/products/components/ProductFilters.d.ts +19 -1
- package/dist/features/products/components/ProductFilters.js +11 -3
- package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
- package/dist/features/products/components/ProductsIndexListing.js +40 -27
- package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
- package/dist/features/reviews/repository/reviews.repository.js +16 -19
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +161 -17
- package/dist/providers/db-firebase/base.repository.d.ts +12 -0
- package/dist/providers/db-firebase/base.repository.js +14 -2
- 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/security/pii-schemas.d.ts +19 -1
- package/dist/security/pii-schemas.js +19 -1
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import { ShoppingCart, Heart, Columns } from "lucide-react";
|
|
|
5
5
|
import { useRouter } from "next/navigation";
|
|
6
6
|
import { useUrlTable } from "../../../react/hooks/useUrlTable";
|
|
7
7
|
import { useProducts } from "../hooks/useProducts";
|
|
8
|
-
import { BulkActionBar, Div,
|
|
8
|
+
import { BulkActionBar, Div, FilterDrawer, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
|
|
9
9
|
import { usePendingTable } from "../../../react/hooks/usePendingTable";
|
|
10
10
|
import { useAuthGate } from "../../../react/hooks/useAuthGate";
|
|
11
11
|
import { ACTION_ID, ACTION_META, COMPARE_MAX_ITEMS } from "../constants/action-defs";
|
|
@@ -40,7 +40,27 @@ const __O = {
|
|
|
40
40
|
hidden: "overflow-hidden",
|
|
41
41
|
};
|
|
42
42
|
const DEFAULT_SORT = sortBy(PRODUCT_FIELDS.CREATED_AT);
|
|
43
|
-
|
|
43
|
+
// `LISTING_TYPE` is a managed filter key like any other: it stages in the
|
|
44
|
+
// drawer, counts toward the filter badge, and is cleared by Reset. It used to
|
|
45
|
+
// be driven by a separate always-visible chip row that wrote the URL directly,
|
|
46
|
+
// which was the one facet bypassing the Apply model everything else uses.
|
|
47
|
+
const FILTER_KEYS = [TABLE_KEYS.LISTING_TYPE, TABLE_KEYS.CATEGORY, TABLE_KEYS.CONDITION, TABLE_KEYS.MIN_PRICE, TABLE_KEYS.MAX_PRICE, TABLE_KEYS.BRAND, TABLE_KEYS.STORE_ID, TABLE_KEYS.FREE_SHIPPING, TABLE_KEYS.TAGS, TABLE_KEYS.FEATURES, TABLE_KEYS.IS_PART_OF_BUNDLE];
|
|
48
|
+
/**
|
|
49
|
+
* The dedicated per-type browse page for a selection, when there is exactly
|
|
50
|
+
* one type and that type has its own page — which carries facets this generic
|
|
51
|
+
* one can't (bid range, delivery-date window, reveal status).
|
|
52
|
+
*/
|
|
53
|
+
function dedicatedPageFor(types) {
|
|
54
|
+
if (types.length !== 1)
|
|
55
|
+
return null;
|
|
56
|
+
const plugin = pluginFor(types[0]);
|
|
57
|
+
if (!plugin.browseRoute)
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
href: plugin.browseRoute,
|
|
61
|
+
label: `Full ${plugin.pluralLabel} filters →`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
44
64
|
export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODUCT_LISTING_TYPES, typeTabs = PRODUCT_TYPE_FILTER_TABS, searchPlaceholder = "Search products...", }) {
|
|
45
65
|
const router = useRouter();
|
|
46
66
|
const table = useUrlTable({ defaults: { pageSize: "24", sort: DEFAULT_SORT } });
|
|
@@ -74,36 +94,39 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
|
|
|
74
94
|
// keeps the SSR paint and the first refetch in agreement (Root Cause #30).
|
|
75
95
|
const availability = table.get(TABLE_KEYS.AVAILABILITY) ||
|
|
76
96
|
AVAILABILITY_VALUES.AVAILABLE;
|
|
77
|
-
// "Full <type> filters →" — a single selected type with a dedicated browse
|
|
78
|
-
// page gets a link to it, since that page carries facets this generic one
|
|
79
|
-
// can't (bid range, delivery-date window, reveal status).
|
|
80
|
-
const dedicatedPage = selectedTypes.length === 1 && pluginFor(selectedTypes[0]).browseRoute
|
|
81
|
-
? {
|
|
82
|
-
href: pluginFor(selectedTypes[0]).browseRoute,
|
|
83
|
-
label: `Full ${pluginFor(selectedTypes[0]).pluralLabel} filters →`,
|
|
84
|
-
}
|
|
85
|
-
: null;
|
|
86
97
|
const { pendingTable, filterActiveCount, onFilterApply, onFilterClear, onResetAll, onFilterReset } = usePendingTable(table, FILTER_KEYS);
|
|
98
|
+
// The drawer's own, uncommitted type selection. The "Full <type> filters →"
|
|
99
|
+
// link tracks THIS rather than the applied URL value, so it appears the
|
|
100
|
+
// moment the user ticks a single type instead of only after Apply.
|
|
101
|
+
const pendingTypes = parseSelectedListingTypes(pendingTable.get(TABLE_KEYS.LISTING_TYPE)).filter((t) => listingTypes.includes(t));
|
|
102
|
+
const dedicatedPage = dedicatedPageFor(pendingTypes);
|
|
103
|
+
const listingTypeOptions = typeTabs.map((t) => ({ value: t.id, label: t.label }));
|
|
87
104
|
const openFilters = useCallback(() => {
|
|
88
105
|
onFilterReset();
|
|
89
106
|
setFilterOpen(true);
|
|
90
107
|
}, [onFilterReset]);
|
|
91
108
|
const applyFilters = useCallback(() => {
|
|
92
|
-
|
|
109
|
+
// A sort valid for the old type selection (say -auctionEndDate) can be
|
|
110
|
+
// invalid for the new one, so it is blanked in the SAME setMany as the
|
|
111
|
+
// filters — a follow-up table.set would read stale searchParams and
|
|
112
|
+
// overwrite this update (Root Cause #13).
|
|
113
|
+
const key = (types) => [...types].sort().join("|");
|
|
114
|
+
const typesChanged = key(pendingTypes) !== key(selectedTypes);
|
|
115
|
+
onFilterApply(typesChanged ? { [TABLE_KEYS.SORT]: "" } : undefined);
|
|
93
116
|
setFilterOpen(false);
|
|
94
|
-
}, [onFilterApply]);
|
|
117
|
+
}, [onFilterApply, pendingTypes, selectedTypes]);
|
|
95
118
|
const resetAll = useCallback(() => {
|
|
119
|
+
// `listingType` is a managed filter key now, so onResetAll clears it —
|
|
120
|
+
// repeating it here would be a second write of the same value.
|
|
96
121
|
onResetAll({
|
|
97
122
|
[TABLE_KEYS.QUERY]: "",
|
|
98
123
|
[TABLE_KEYS.SORT]: "",
|
|
99
124
|
[TABLE_KEYS.AVAILABILITY]: "",
|
|
100
|
-
[TABLE_KEYS.LISTING_TYPE]: "",
|
|
101
125
|
});
|
|
102
126
|
setSearchInput("");
|
|
103
127
|
}, [onResetAll]);
|
|
104
128
|
const hasActiveState = !!table.get(TABLE_KEYS.QUERY) ||
|
|
105
129
|
availability !== AVAILABILITY_VALUES.AVAILABLE ||
|
|
106
|
-
!!typeParam ||
|
|
107
130
|
table.get(TABLE_KEYS.SORT) !== DEFAULT_SORT ||
|
|
108
131
|
filterActiveCount > 0;
|
|
109
132
|
const localCart = useGuestCart();
|
|
@@ -263,17 +286,7 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
|
|
|
263
286
|
// → close for every toggle. The full facet set (category/price/brand/…)
|
|
264
287
|
// remains in <FilterDrawer> below.
|
|
265
288
|
{ label: "Free shipping", active: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (next) => table.set(TABLE_KEYS.FREE_SHIPPING, next ? "true" : "") },
|
|
266
|
-
] }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: effectiveTypes }) }), _jsx(
|
|
267
|
-
// Clear `sort` alongside the type change: a sort valid for the
|
|
268
|
-
// old selection (say -auctionEndDate) can be invalid for the new
|
|
269
|
-
// one, and useUrlTable's single router.replace means we must set
|
|
270
|
-
// both in ONE call — a follow-up setPage/set would read stale
|
|
271
|
-
// searchParams and overwrite this update (Root Cause #13).
|
|
272
|
-
table.setMany({
|
|
273
|
-
[TABLE_KEYS.LISTING_TYPE]: next,
|
|
274
|
-
[TABLE_KEYS.SORT]: "",
|
|
275
|
-
});
|
|
276
|
-
} }), dedicatedPage && (_jsx(TextLink, { href: dedicatedPage.href, size: "sm", weight: "medium", children: dedicatedPage.label }))] }) }), _jsx(BulkActionBar, { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
|
|
289
|
+
] }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: effectiveTypes }) }), _jsx(BulkActionBar, { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
|
|
277
290
|
{
|
|
278
291
|
id: ACTION_ID.ADD_TO_CART,
|
|
279
292
|
label: ACTION_META[ACTION_ID.ADD_TO_CART].label,
|
|
@@ -321,5 +334,5 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
|
|
|
321
334
|
] }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { layout: "grid", gap: "4", className: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4", children: Array.from({ length: 10 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-1/3", surface: "subtle", rounded: "default" })] })] }, i))) })) : (_jsx(ProductGrid, { products: products, getProductHref: (p) => pluginFor(p.listingType ?? "standard").detailRoute(p.slug || p.id), view: view === "grid" ? "card" : "list", onWishlistToggle: handleWishlistToggle, wishlistedIds: wishlistedIds, onAddToCart: handleAddToCart, onBuyNow: handleBuyNow, selectionMode: selection.isSelecting, selectedIds: selection.selectedIdSet, onToggleSelect: selection.toggle })) }), _jsx(CompareOverlay, { isOpen: compareIds.length > 0, productIds: compareIds, productType: "product", onClose: () => {
|
|
322
335
|
setCompareIds([]);
|
|
323
336
|
selection.clearSelection();
|
|
324
|
-
}, onRemove: (id) => setCompareIds((ids) => ids.filter((i) => i !== id)) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9", categoryOptions: categoryOptions, brandOptions: brandOptions, featureOptions: featureOptions }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
|
|
337
|
+
}, onRemove: (id) => setCompareIds((ids) => ids.filter((i) => i !== id)) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9", listingTypeOptions: listingTypeOptions, listingTypeFooter: dedicatedPage ? (_jsx(Div, { padding: "x-md", className: "pb-[var(--appkit-space-3)]", children: _jsx(TextLink, { href: dedicatedPage.href, size: "sm", weight: "medium", children: dedicatedPage.label }) })) : null, categoryOptions: categoryOptions, brandOptions: brandOptions, featureOptions: featureOptions }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
|
|
325
338
|
}
|
|
@@ -11,16 +11,18 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
|
11
11
|
constructor();
|
|
12
12
|
private decryptSieveResult;
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Declared, not hand-rolled.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
17
|
+
* `encryptReviewData` did — encrypt the listed fields, then add the mapped blind
|
|
18
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
19
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
20
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
21
|
+
* encryption lived in a private method only three call sites remembered to
|
|
22
|
+
* call.
|
|
22
23
|
*/
|
|
23
|
-
|
|
24
|
+
protected piiFields: readonly ["userName"];
|
|
25
|
+
protected piiIndexMap: Record<string, string>;
|
|
24
26
|
protected mapDoc<D = ReviewDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
|
|
25
27
|
create(input: ReviewCreateInput): Promise<ReviewDocument>;
|
|
26
28
|
update(reviewId: string, data: Partial<ReviewDocument>): Promise<ReviewDocument>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
2
2
|
import { NotFoundError } from "../../../errors";
|
|
3
3
|
import { REVIEW_MAX_RATING, REVIEW_MIN_RATING } from "../../../_internal/shared/features/reviews/config";
|
|
4
|
-
import { decryptPiiFields,
|
|
4
|
+
import { decryptPiiFields, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
|
|
5
5
|
import { createReviewId, REVIEW_COLLECTION, } from "../schemas";
|
|
6
6
|
import { buildReviewSearchTxt } from "../../../utils/search-txt-builders";
|
|
7
7
|
import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
|
|
@@ -21,6 +21,19 @@ const REVIEW_FIELDS = {
|
|
|
21
21
|
class ReviewRepository extends BaseRepository {
|
|
22
22
|
constructor() {
|
|
23
23
|
super(REVIEW_COLLECTION);
|
|
24
|
+
/**
|
|
25
|
+
* Declared, not hand-rolled.
|
|
26
|
+
*
|
|
27
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
28
|
+
* `encryptReviewData` did — encrypt the listed fields, then add the mapped blind
|
|
29
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
30
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
31
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
32
|
+
* encryption lived in a private method only three call sites remembered to
|
|
33
|
+
* call.
|
|
34
|
+
*/
|
|
35
|
+
this.piiFields = REVIEW_PII_FIELDS;
|
|
36
|
+
this.piiIndexMap = REVIEW_PII_INDEX_MAP;
|
|
24
37
|
}
|
|
25
38
|
decryptSieveResult(result) {
|
|
26
39
|
return {
|
|
@@ -30,22 +43,6 @@ class ReviewRepository extends BaseRepository {
|
|
|
30
43
|
])),
|
|
31
44
|
};
|
|
32
45
|
}
|
|
33
|
-
/**
|
|
34
|
-
* Encrypt PII, then attach blind indices derived from the plaintext.
|
|
35
|
-
*
|
|
36
|
-
* Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
|
|
37
|
-
* re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
|
|
38
|
-
* then spread that over the ciphertext, so plaintext won and `userName` was
|
|
39
|
-
* written to Firestore in the clear beside a valid index. `piiIndicesFor`
|
|
40
|
-
* returns the index fields alone, so the ciphertext cannot be overwritten.
|
|
41
|
-
* The identical bug was fixed in UserRepository and never propagated here.
|
|
42
|
-
*/
|
|
43
|
-
encryptReviewData(data) {
|
|
44
|
-
return {
|
|
45
|
-
...encryptPiiFields(data, [...REVIEW_PII_FIELDS]),
|
|
46
|
-
...piiIndicesFor(data, REVIEW_PII_INDEX_MAP),
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
46
|
mapDoc(snap) {
|
|
50
47
|
const raw = super.mapDoc(snap);
|
|
51
48
|
// `helpfulVoterIds` is an idempotency key, not review content — publishing it
|
|
@@ -71,7 +68,7 @@ class ReviewRepository extends BaseRepository {
|
|
|
71
68
|
createdAt: new Date(),
|
|
72
69
|
updatedAt: new Date(),
|
|
73
70
|
};
|
|
74
|
-
const encrypted = this.
|
|
71
|
+
const encrypted = this.applyWriteHooks(reviewData);
|
|
75
72
|
await this.db
|
|
76
73
|
.collection(this.collection)
|
|
77
74
|
.doc(id)
|
|
@@ -79,7 +76,7 @@ class ReviewRepository extends BaseRepository {
|
|
|
79
76
|
return { id, ...reviewData };
|
|
80
77
|
}
|
|
81
78
|
async update(reviewId, data) {
|
|
82
|
-
const encrypted = this.
|
|
79
|
+
const encrypted = this.applyWriteHooks(data);
|
|
83
80
|
return super.update(reviewId, encrypted);
|
|
84
81
|
}
|
|
85
82
|
async findByProduct(productId) {
|
|
@@ -220,52 +220,70 @@ const rawTesterChecklistItems = [
|
|
|
220
220
|
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
221
|
href: "/products",
|
|
222
222
|
},
|
|
223
|
+
{
|
|
224
|
+
key: "product-type-filter-lives-in-drawer",
|
|
225
|
+
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",
|
|
226
|
+
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.",
|
|
227
|
+
href: "/products",
|
|
228
|
+
},
|
|
223
229
|
{
|
|
224
230
|
key: "product-type-chips-cover-all-types",
|
|
225
|
-
label: "The
|
|
226
|
-
description: "
|
|
231
|
+
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",
|
|
232
|
+
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
233
|
href: "/products",
|
|
228
234
|
},
|
|
229
235
|
{
|
|
230
236
|
key: "product-type-chips-multi-select",
|
|
231
|
-
label: "The
|
|
232
|
-
description: "Tick Auctions, then tick Pre-Orders WITHOUT unticking Auctions. Both must stay
|
|
237
|
+
label: "The \"Listing type\" boxes are CHECKBOXES, not radio buttons — ticking Auctions + Pre-Orders and pressing Apply shows both types in one grid",
|
|
238
|
+
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
239
|
href: "/products",
|
|
234
240
|
},
|
|
235
241
|
{
|
|
236
242
|
key: "product-type-chips-none-means-all",
|
|
237
|
-
label: "With NO type
|
|
243
|
+
label: "With NO \"Listing type\" box ticked, /products shows a mix of listing types — check the coloured type badges on the cards",
|
|
238
244
|
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
245
|
href: "/products",
|
|
240
246
|
},
|
|
247
|
+
{
|
|
248
|
+
key: "product-type-filter-counts-in-badge",
|
|
249
|
+
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",
|
|
250
|
+
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.",
|
|
251
|
+
href: "/products",
|
|
252
|
+
},
|
|
241
253
|
{
|
|
242
254
|
key: "product-type-chip-drives-sort-options",
|
|
243
|
-
label: "
|
|
244
|
-
description: "
|
|
255
|
+
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\"",
|
|
256
|
+
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
257
|
href: "/products",
|
|
246
258
|
},
|
|
247
259
|
{
|
|
248
260
|
key: "product-type-chip-sort-resets-no-empty-page",
|
|
249
261
|
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.
|
|
262
|
+
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
263
|
href: "/products",
|
|
252
264
|
},
|
|
253
265
|
{
|
|
254
266
|
key: "product-type-toggles-follow-selection",
|
|
255
|
-
label: "The
|
|
256
|
-
description: "
|
|
267
|
+
label: "The middle availability tab is worded for the applied types — \"Ended\" for Auctions alone, \"Sold\" for Standard alone, \"Sold & Ended\" with nothing applied",
|
|
268
|
+
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
269
|
href: "/products",
|
|
258
270
|
},
|
|
259
271
|
{
|
|
260
272
|
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: "
|
|
273
|
+
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",
|
|
274
|
+
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
275
|
href: "/products",
|
|
264
276
|
},
|
|
265
277
|
{
|
|
266
278
|
key: "product-type-selection-survives-reload",
|
|
267
|
-
label: "The
|
|
268
|
-
description: "
|
|
279
|
+
label: "The applied listing types are reflected in the URL and survive a reload and the browser Back button",
|
|
280
|
+
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.",
|
|
281
|
+
href: "/products",
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
key: "product-type-pending-until-apply",
|
|
285
|
+
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",
|
|
286
|
+
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
287
|
href: "/products",
|
|
270
288
|
},
|
|
271
289
|
{
|
|
@@ -282,8 +300,8 @@ const rawTesterChecklistItems = [
|
|
|
282
300
|
},
|
|
283
301
|
{
|
|
284
302
|
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
|
|
303
|
+
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",
|
|
304
|
+
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
305
|
href: "/products",
|
|
288
306
|
},
|
|
289
307
|
{
|
|
@@ -294,7 +312,8 @@ const rawTesterChecklistItems = [
|
|
|
294
312
|
},
|
|
295
313
|
{
|
|
296
314
|
key: "art-stickers-type-chips-narrow",
|
|
297
|
-
label: "On /art, the \"
|
|
315
|
+
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",
|
|
316
|
+
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
317
|
href: "/art",
|
|
299
318
|
},
|
|
300
319
|
{
|
|
@@ -2338,6 +2357,131 @@ const rawTesterChecklistItems = [
|
|
|
2338
2357
|
{ 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
2358
|
],
|
|
2340
2359
|
},
|
|
2360
|
+
{
|
|
2361
|
+
pageKey: "search",
|
|
2362
|
+
pageLabel: "Search",
|
|
2363
|
+
href: "/products",
|
|
2364
|
+
cases: [
|
|
2365
|
+
{
|
|
2366
|
+
key: "search-typeahead-differs",
|
|
2367
|
+
label: "Typing \"dranzer\" and then \"dragoon\" in the header search gives DIFFERENT suggestions",
|
|
2368
|
+
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.",
|
|
2369
|
+
},
|
|
2370
|
+
{
|
|
2371
|
+
key: "search-typeahead-no-drafts",
|
|
2372
|
+
label: "Header search suggestions contain no draft or archived listings",
|
|
2373
|
+
description: "WAS: the suggestion query applied no status filter, so unpublished drafts were visible to the public.",
|
|
2374
|
+
},
|
|
2375
|
+
{
|
|
2376
|
+
key: "search-prefix-match",
|
|
2377
|
+
label: "Searching \"dran\" finds Dranzer products on /products",
|
|
2378
|
+
description: "WAS: no match at all - only whole words matched, so a partial term found nothing.",
|
|
2379
|
+
href: "/products",
|
|
2380
|
+
},
|
|
2381
|
+
{
|
|
2382
|
+
key: "search-both-words-required",
|
|
2383
|
+
label: "Searching \"red dranzer\" returns only items matching BOTH words, not either one",
|
|
2384
|
+
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.",
|
|
2385
|
+
href: "/products",
|
|
2386
|
+
},
|
|
2387
|
+
{
|
|
2388
|
+
key: "search-plus-category-filter",
|
|
2389
|
+
label: "A search term AND a category filter apply together on /products",
|
|
2390
|
+
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.",
|
|
2391
|
+
href: "/products",
|
|
2392
|
+
},
|
|
2393
|
+
{
|
|
2394
|
+
key: "search-keeps-sort",
|
|
2395
|
+
label: "Changing the sort while a search term is active re-orders the results and keeps the term",
|
|
2396
|
+
description: "WAS: the sort was silently discarded whenever a term was present, so the dropdown said \"Oldest\" over unsorted rows.",
|
|
2397
|
+
href: "/products",
|
|
2398
|
+
},
|
|
2399
|
+
{
|
|
2400
|
+
key: "search-keeps-facets",
|
|
2401
|
+
label: "On /products, searching keeps the price, tag and availability facets working",
|
|
2402
|
+
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.",
|
|
2403
|
+
href: "/products",
|
|
2404
|
+
},
|
|
2405
|
+
{
|
|
2406
|
+
key: "search-case-and-accents",
|
|
2407
|
+
label: "\"POKEMON\" with an accent, without one, and capitalised all return the same results",
|
|
2408
|
+
description: "WAS: matching was case- and accent-sensitive, so only the exact stored spelling matched.",
|
|
2409
|
+
href: "/products",
|
|
2410
|
+
},
|
|
2411
|
+
{
|
|
2412
|
+
key: "search-single-char-narrows",
|
|
2413
|
+
label: "A single-character query narrows the list instead of showing everything",
|
|
2414
|
+
description: "WAS: a 1-char term was discarded as too short and the unfiltered collection came back - indistinguishable from a search that matched everything.",
|
|
2415
|
+
href: "/products",
|
|
2416
|
+
},
|
|
2417
|
+
{
|
|
2418
|
+
key: "search-faqs",
|
|
2419
|
+
label: "Searching on /faqs returns matching questions",
|
|
2420
|
+
description: "WAS: this is the report that started the whole migration - 63 FAQs existed, none had search tokens, so every query returned empty.",
|
|
2421
|
+
href: "/faqs",
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
key: "search-faq-category-page",
|
|
2425
|
+
label: "Opening a FAQ category page renders its questions",
|
|
2426
|
+
description: "WAS: a missing index threw FAILED_PRECONDITION, which was swallowed, leaving a blank page with no error.",
|
|
2427
|
+
href: "/faqs",
|
|
2428
|
+
},
|
|
2429
|
+
{
|
|
2430
|
+
key: "search-scams-listing",
|
|
2431
|
+
label: "/scams lists the verified scammer profiles",
|
|
2432
|
+
description: "WAS: \"No verified scammers yet\" always, because the query needed an index that did not exist.",
|
|
2433
|
+
href: "/scams",
|
|
2434
|
+
},
|
|
2435
|
+
{
|
|
2436
|
+
key: "search-scam-partial-and-upi",
|
|
2437
|
+
label: "On /admin/scammers, searching \"Vikram\" finds \"Vikram Mehta\", and searching a UPI id also finds its profile",
|
|
2438
|
+
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.",
|
|
2439
|
+
href: "/admin/scammers",
|
|
2440
|
+
},
|
|
2441
|
+
{
|
|
2442
|
+
key: "search-store-event-blog-review",
|
|
2443
|
+
label: "Store, event, blog and review searches each return matches",
|
|
2444
|
+
description: "WAS: never implemented for any of the four - the box existed and the endpoint ignored the term.",
|
|
2445
|
+
href: "/blog",
|
|
2446
|
+
},
|
|
2447
|
+
{
|
|
2448
|
+
key: "search-admin-exact-match",
|
|
2449
|
+
label: "On /admin/reviews a FULL reviewer name matches, a partial one matches nothing, and the box says \"exact match\"",
|
|
2450
|
+
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.",
|
|
2451
|
+
href: "/admin/reviews",
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
key: "search-admin-degraded-sort",
|
|
2455
|
+
label: "Searching on /admin/payouts shows a notice that results are not sorted while searching",
|
|
2456
|
+
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.",
|
|
2457
|
+
href: "/admin/payouts",
|
|
2458
|
+
},
|
|
2459
|
+
{
|
|
2460
|
+
key: "search-admin-team-filter-chip",
|
|
2461
|
+
label: "On /admin/team, applying any filter chip still returns employees",
|
|
2462
|
+
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.",
|
|
2463
|
+
href: "/admin/team",
|
|
2464
|
+
},
|
|
2465
|
+
{
|
|
2466
|
+
key: "search-no-empty-toolbar-gap",
|
|
2467
|
+
label: "A listing page whose search box was removed shows no empty gap in its toolbar",
|
|
2468
|
+
description: "31 boxes were removed because their endpoints ignored the term. The toolbar should close up, not leave a hole where the input was.",
|
|
2469
|
+
href: "/admin/coupons",
|
|
2470
|
+
},
|
|
2471
|
+
{
|
|
2472
|
+
key: "search-nonsense-term-returns-nothing",
|
|
2473
|
+
label: "Searching \"zzzznope\" anywhere returns ZERO results",
|
|
2474
|
+
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.",
|
|
2475
|
+
href: "/products",
|
|
2476
|
+
},
|
|
2477
|
+
{
|
|
2478
|
+
key: "search-finds-older-records",
|
|
2479
|
+
label: "An OLDER listing - one that existed before this feature shipped - is findable by name",
|
|
2480
|
+
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.",
|
|
2481
|
+
href: "/products",
|
|
2482
|
+
},
|
|
2483
|
+
],
|
|
2484
|
+
},
|
|
2341
2485
|
]),
|
|
2342
2486
|
...group("community-support", "Community & Support", [
|
|
2343
2487
|
{
|
|
@@ -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;
|
|
@@ -218,10 +218,22 @@ export class BaseRepository {
|
|
|
218
218
|
throw new DatabaseError(`Failed to check document existence: ${id}`, error);
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Server-side aggregation — never a full read.
|
|
223
|
+
*
|
|
224
|
+
* This used to be `getCollection().get()` then `.size`, which downloads every
|
|
225
|
+
* document in the collection to learn how many there are. On `products` or
|
|
226
|
+
* `orders` that is the entire collection billed as reads, and the payload is
|
|
227
|
+
* discarded immediately. Straight Rule #6 violation, and it scales with the
|
|
228
|
+
* data rather than staying flat.
|
|
229
|
+
*
|
|
230
|
+
* `count()` is a Firestore aggregation query: the server returns a number,
|
|
231
|
+
* billed at one read per 1000 documents matched rather than one per document.
|
|
232
|
+
*/
|
|
221
233
|
async count() {
|
|
222
234
|
try {
|
|
223
|
-
const snapshot = await this.getCollection().get();
|
|
224
|
-
return snapshot.
|
|
235
|
+
const snapshot = await this.getCollection().count().get();
|
|
236
|
+
return snapshot.data().count;
|
|
225
237
|
}
|
|
226
238
|
catch (error) {
|
|
227
239
|
void normalizeError(error);
|
|
@@ -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
|
/**
|
|
@@ -5,7 +5,25 @@
|
|
|
5
5
|
* encrypted before storage and decrypted on read.
|
|
6
6
|
*/
|
|
7
7
|
/** PII fields in the users collection (top-level string fields) */
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `googleLinkedEmail` was added 2026-08-28, found by audit-pii-coverage.
|
|
10
|
+
*
|
|
11
|
+
* It is a real email address, written in cleartext by the Google OAuth callback
|
|
12
|
+
* (`api/auth/google/callback`, three write sites) and stored on the user
|
|
13
|
+
* document beside `email` — which has always been encrypted. So one user's two
|
|
14
|
+
* email addresses sat in the same document, one ciphertext and one plaintext,
|
|
15
|
+
* and nothing pointed at the difference.
|
|
16
|
+
*
|
|
17
|
+
* No blind index: nothing queries by it. Its only reader is the account
|
|
18
|
+
* holder's own profile ("Signed in as …"), which is a legitimate plaintext read.
|
|
19
|
+
*
|
|
20
|
+
* Existing rows stay plaintext until backfilled and keep reading correctly —
|
|
21
|
+
* `decryptValue` passes a non-`enc:v1:` string through unchanged — so new
|
|
22
|
+
* writes encrypt while old rows converge, never diverge. Add
|
|
23
|
+
* `googleLinkedEmail` to the `users` target in `backfill-pii.mjs` when that
|
|
24
|
+
* backfill is next run.
|
|
25
|
+
*/
|
|
26
|
+
export declare const USER_PII_FIELDS: readonly ["email", "phoneNumber", "googleLinkedEmail"];
|
|
9
27
|
/** Blind-index mapping for queryable user PII: source → index field name */
|
|
10
28
|
export declare const USER_PII_INDEX_MAP: Record<string, string>;
|
|
11
29
|
/** PII fields in the addresses subcollection */
|
|
@@ -5,7 +5,25 @@
|
|
|
5
5
|
* encrypted before storage and decrypted on read.
|
|
6
6
|
*/
|
|
7
7
|
/** PII fields in the users collection (top-level string fields) */
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `googleLinkedEmail` was added 2026-08-28, found by audit-pii-coverage.
|
|
10
|
+
*
|
|
11
|
+
* It is a real email address, written in cleartext by the Google OAuth callback
|
|
12
|
+
* (`api/auth/google/callback`, three write sites) and stored on the user
|
|
13
|
+
* document beside `email` — which has always been encrypted. So one user's two
|
|
14
|
+
* email addresses sat in the same document, one ciphertext and one plaintext,
|
|
15
|
+
* and nothing pointed at the difference.
|
|
16
|
+
*
|
|
17
|
+
* No blind index: nothing queries by it. Its only reader is the account
|
|
18
|
+
* holder's own profile ("Signed in as …"), which is a legitimate plaintext read.
|
|
19
|
+
*
|
|
20
|
+
* Existing rows stay plaintext until backfilled and keep reading correctly —
|
|
21
|
+
* `decryptValue` passes a non-`enc:v1:` string through unchanged — so new
|
|
22
|
+
* writes encrypt while old rows converge, never diverge. Add
|
|
23
|
+
* `googleLinkedEmail` to the `users` target in `backfill-pii.mjs` when that
|
|
24
|
+
* backfill is next run.
|
|
25
|
+
*/
|
|
26
|
+
export const USER_PII_FIELDS = ["email", "phoneNumber", "googleLinkedEmail"];
|
|
9
27
|
/** Blind-index mapping for queryable user PII: source → index field name */
|
|
10
28
|
export const USER_PII_INDEX_MAP = {
|
|
11
29
|
email: "emailIndex",
|