@mohasinac/appkit 4.30.13 → 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/admin/components/AdminBlogView.js +8 -1
- package/dist/features/admin/components/AdminCouponsView.js +8 -1
- package/dist/features/admin/components/AdminOffersView.js +9 -1
- package/dist/features/admin/components/AdminPayoutsView.js +9 -1
- package/dist/features/admin/components/AdminReviewsView.js +14 -1
- package/dist/features/admin/components/AdminScammersView.js +15 -1
- package/dist/features/admin/components/DataListingView.d.ts +22 -1
- package/dist/features/admin/components/DataListingView.js +7 -0
- package/dist/features/admin/hooks/useAdminListing.d.ts +9 -0
- package/dist/features/admin/hooks/useAdminListing.js +30 -2
- 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/events/components/AdminEventsView.js +7 -1
- 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/scams/repository/scammer.repository.d.ts +10 -1
- package/dist/features/scams/repository/scammer.repository.js +20 -2
- package/dist/features/scams/schemas/firestore.d.ts +10 -0
- package/dist/features/scams/schemas/firestore.js +1 -0
- package/dist/features/scams/schemas/index.d.ts +10 -10
- package/dist/features/seller/components/SellerCouponsView.js +7 -1
- package/dist/features/seller/components/SellerOffersView.js +8 -1
- package/dist/features/seller/components/SellerOrdersView.js +8 -1
- 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/schemas/registry.d.ts +8 -8
- package/dist/security/pii-schemas.d.ts +19 -1
- package/dist/security/pii-schemas.js +19 -1
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +2 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +4 -1
- package/dist/seed/scammers-seed-data.js +10 -1
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/search-txt-builders.d.ts +13 -0
- package/dist/utils/search-txt-builders.js +20 -0
- package/package.json +2 -9
|
@@ -64,8 +64,23 @@ export const listingProcessor = defineFunction({
|
|
|
64
64
|
handler: listingProcessorHandler,
|
|
65
65
|
options: {
|
|
66
66
|
region: REGION,
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Matched to the `gateway` function's budget ON PURPOSE, and this is not
|
|
68
|
+
// over-provisioning.
|
|
69
|
+
//
|
|
70
|
+
// `listingProcessorHandler` is reachable two ways: this direct function,
|
|
71
|
+
// and `gateway` with `action: "listingProcessor"` (it is in
|
|
72
|
+
// GATEWAY_HANDLERS). `src/lib/listing-processor.ts` chooses between them on
|
|
73
|
+
// whether FIREBASE_FUNCTION_GATEWAY_URL is set — so an ENVIRONMENT VARIABLE
|
|
74
|
+
// decided which limits the same query ran under. At 30s/256MiB here against
|
|
75
|
+
// the gateway's 120s/512MiB, a heavy listing query could succeed through
|
|
76
|
+
// one path and time out through the other, with nothing at the call site
|
|
77
|
+
// saying which path served it. That is the shape of a production bug nobody
|
|
78
|
+
// can reproduce.
|
|
79
|
+
//
|
|
80
|
+
// Cloud Functions bill on use, not on the declared ceiling, so aligning the
|
|
81
|
+
// two costs nothing while idle and removes the non-determinism.
|
|
82
|
+
timeoutSeconds: 120,
|
|
83
|
+
memory: "512MiB",
|
|
69
84
|
maxInstances: 20,
|
|
70
85
|
minInstances: 0,
|
|
71
86
|
cors: false,
|
|
@@ -11,7 +11,11 @@ import { normalizeError } from "../../../../errors/normalize";
|
|
|
11
11
|
const DEFAULT_SORT = "-createdAt";
|
|
12
12
|
const DEFAULT_PAGE = 1;
|
|
13
13
|
const DEFAULT_PAGE_SIZE = 20;
|
|
14
|
-
|
|
14
|
+
// 50, matching the app's own ceiling. This was 100, so a caller reaching the
|
|
15
|
+
// Function could ask for twice what every route permits — and the routes are
|
|
16
|
+
// where Rule #6's `pageSize <= 50` is enforced. A limit that only one of two
|
|
17
|
+
// entry points applies is not a limit.
|
|
18
|
+
const MAX_PAGE_SIZE = 50;
|
|
15
19
|
class ListingError extends Error {
|
|
16
20
|
constructor(status, message) {
|
|
17
21
|
super(message);
|
|
@@ -3,7 +3,18 @@ import { type AddressBanStatus, type AddressCreateInput, type AddressDocument, t
|
|
|
3
3
|
export declare class AddressesRepository extends BaseRepository<AddressDocument> {
|
|
4
4
|
constructor();
|
|
5
5
|
private decryptAddress;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Declared, not hand-rolled.
|
|
8
|
+
*
|
|
9
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
10
|
+
* `encryptAddressData` did — encrypt the listed fields, then add the mapped blind
|
|
11
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
12
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
13
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
14
|
+
* encryption lived in a private method only three call sites remembered to
|
|
15
|
+
* call.
|
|
16
|
+
*/
|
|
17
|
+
protected piiFields: readonly ["fullName", "phone", "addressLine1", "addressLine2"];
|
|
7
18
|
protected mapDoc<D = AddressDocument>(snap: DocumentSnapshot): D;
|
|
8
19
|
createWithId(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
|
|
9
20
|
update(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
|
|
@@ -13,28 +13,37 @@ import { normalizeError } from "../../../errors/normalize";
|
|
|
13
13
|
import { DatabaseError } from "../../../errors";
|
|
14
14
|
import { serverLogger } from "../../../monitoring";
|
|
15
15
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, } from "../../../providers/db-firebase";
|
|
16
|
-
import { ADDRESS_PII_FIELDS, decryptPiiFields,
|
|
16
|
+
import { ADDRESS_PII_FIELDS, decryptPiiFields, } from "../../../security";
|
|
17
17
|
import { ADDRESS_FIELDS, ADDRESSES_COLLECTION, } from "../schemas";
|
|
18
18
|
export class AddressesRepository extends BaseRepository {
|
|
19
19
|
constructor() {
|
|
20
20
|
super(ADDRESSES_COLLECTION);
|
|
21
|
+
/**
|
|
22
|
+
* Declared, not hand-rolled.
|
|
23
|
+
*
|
|
24
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
25
|
+
* `encryptAddressData` did — encrypt the listed fields, then add the mapped blind
|
|
26
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
27
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
28
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
29
|
+
* encryption lived in a private method only three call sites remembered to
|
|
30
|
+
* call.
|
|
31
|
+
*/
|
|
32
|
+
this.piiFields = ADDRESS_PII_FIELDS;
|
|
21
33
|
}
|
|
22
34
|
decryptAddress(doc) {
|
|
23
35
|
return decryptPiiFields(doc, [...ADDRESS_PII_FIELDS]);
|
|
24
36
|
}
|
|
25
|
-
encryptAddressData(data) {
|
|
26
|
-
return encryptPiiFields(data, [...ADDRESS_PII_FIELDS]);
|
|
27
|
-
}
|
|
28
37
|
mapDoc(snap) {
|
|
29
38
|
const raw = super.mapDoc(snap);
|
|
30
39
|
return this.decryptAddress(raw);
|
|
31
40
|
}
|
|
32
41
|
async createWithId(id, data) {
|
|
33
|
-
const encrypted = this.
|
|
42
|
+
const encrypted = this.applyWriteHooks(data);
|
|
34
43
|
return super.createWithId(id, encrypted);
|
|
35
44
|
}
|
|
36
45
|
async update(id, data) {
|
|
37
|
-
const encrypted = this.
|
|
46
|
+
const encrypted = this.applyWriteHooks(data);
|
|
38
47
|
return super.update(id, encrypted);
|
|
39
48
|
}
|
|
40
49
|
async listByOwner(ownerType, ownerId) {
|
|
@@ -91,7 +100,7 @@ export class AddressesRepository extends BaseRepository {
|
|
|
91
100
|
createdAt: now,
|
|
92
101
|
updatedAt: now,
|
|
93
102
|
};
|
|
94
|
-
const encrypted = this.
|
|
103
|
+
const encrypted = this.applyWriteHooks(addressData);
|
|
95
104
|
await docRef.set(prepareForFirestore(encrypted));
|
|
96
105
|
serverLogger.info("Address created", {
|
|
97
106
|
ownerType,
|
|
@@ -23,7 +23,14 @@ export function AdminBlogView({ children, ...props }) {
|
|
|
23
23
|
title: "Blog Posts",
|
|
24
24
|
// buildBlogSearchTxt indexes title + excerpt + category + tags — NOT the post
|
|
25
25
|
// body, which is not tokenised.
|
|
26
|
-
|
|
26
|
+
// buildBlogSearchTxt indexes title + excerpt + category + tags — NOT the
|
|
27
|
+
// post body, which is not tokenised.
|
|
28
|
+
search: {
|
|
29
|
+
placeholder: "Search by title, excerpt, category or tag…",
|
|
30
|
+
mode: "partial",
|
|
31
|
+
fields: ["title", "excerpt", "category", "tags"],
|
|
32
|
+
commit: "debounce",
|
|
33
|
+
},
|
|
27
34
|
emptyLabel: "No blog posts found",
|
|
28
35
|
filterKeys: ["status", "isFeatured"],
|
|
29
36
|
defaultSort: sortBy("publishedAt", "DESC"),
|
|
@@ -53,7 +53,14 @@ export function AdminCouponsView({ children, onBulkArchive, onBulkDelete, ...pro
|
|
|
53
53
|
portal: "admin",
|
|
54
54
|
title: "Coupons",
|
|
55
55
|
// buildCouponSearchTxt indexes code + name + description — nothing else.
|
|
56
|
-
|
|
56
|
+
// buildCouponSearchTxt indexes exactly these three. Partial prefix
|
|
57
|
+
// matching, so debounce is the right commit here.
|
|
58
|
+
search: {
|
|
59
|
+
placeholder: "Search by code, name or description…",
|
|
60
|
+
mode: "partial",
|
|
61
|
+
fields: ["code", "name", "description"],
|
|
62
|
+
commit: "debounce",
|
|
63
|
+
},
|
|
57
64
|
emptyLabel: "No coupons found",
|
|
58
65
|
filterKeys: ["type", "showExpired"],
|
|
59
66
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
@@ -106,7 +106,15 @@ export function AdminOffersView({ children, ...props }) {
|
|
|
106
106
|
// buildOfferSearchTxt indexes productTitle + productSlug + storeName. It does
|
|
107
107
|
// NOT index buyerName or buyerEmail — those are PII, and the placeholder
|
|
108
108
|
// must not promise a match it will never make.
|
|
109
|
-
|
|
109
|
+
// buildOfferSearchTxt indexes productTitle + productSlug + storeName.
|
|
110
|
+
// NOT buyerName or buyerEmail — those are PII (D1), and a placeholder
|
|
111
|
+
// naming them would promise a match this corpus cannot make.
|
|
112
|
+
search: {
|
|
113
|
+
placeholder: "Search by product or store…",
|
|
114
|
+
mode: "partial",
|
|
115
|
+
fields: ["productTitle", "productSlug", "storeName"],
|
|
116
|
+
commit: "debounce",
|
|
117
|
+
},
|
|
110
118
|
emptyLabel: "No offers found",
|
|
111
119
|
filterKeys: ["status"],
|
|
112
120
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
@@ -81,7 +81,15 @@ export function AdminPayoutsView({ children, ...props }) {
|
|
|
81
81
|
const config = {
|
|
82
82
|
portal: "admin",
|
|
83
83
|
title: "Payouts",
|
|
84
|
-
|
|
84
|
+
// 🛑 This said "Search stores, payout IDs, or order groups". The route
|
|
85
|
+
// resolves `q` as `sellerEmailIndex == piiBlindIndex(q)` when it contains
|
|
86
|
+
// an "@", else `sellerName == q` — both EXACT, and neither is a store, a
|
|
87
|
+
// payout id or an order group. All three promised things were unmatchable.
|
|
88
|
+
search: {
|
|
89
|
+
placeholder: "Seller email or name (exact match)",
|
|
90
|
+
mode: "exact",
|
|
91
|
+
fields: ["sellerEmail", "sellerName"],
|
|
92
|
+
},
|
|
85
93
|
emptyLabel: "No payouts found",
|
|
86
94
|
filterKeys: ["status", "showAllPayouts"],
|
|
87
95
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
@@ -69,7 +69,20 @@ export function AdminReviewsView({ children, ...props }) {
|
|
|
69
69
|
const config = {
|
|
70
70
|
portal: "admin",
|
|
71
71
|
title: "Reviews",
|
|
72
|
-
|
|
72
|
+
// 🛑 This said "Search reviews, products, or seller names". The route
|
|
73
|
+
// resolves `q` as `userNameIndex == piiBlindIndex(q)` — an EXACT match on
|
|
74
|
+
// the REVIEWER's name, and nothing else. It matched no review text, no
|
|
75
|
+
// product and no seller, so a partial name returned an empty list that was
|
|
76
|
+
// indistinguishable from "no such reviewer".
|
|
77
|
+
//
|
|
78
|
+
// `mode: "exact"` is not a limitation to apologise for: the field is
|
|
79
|
+
// encrypted and ciphertext has no usable prefix (D1). Say so in the
|
|
80
|
+
// placeholder instead of promising what the corpus cannot do.
|
|
81
|
+
search: {
|
|
82
|
+
placeholder: "Reviewer name (exact match)",
|
|
83
|
+
mode: "exact",
|
|
84
|
+
fields: ["userName"],
|
|
85
|
+
},
|
|
73
86
|
emptyLabel: "No reviews found",
|
|
74
87
|
filterKeys: ["status", "rating"],
|
|
75
88
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
@@ -42,7 +42,21 @@ export function AdminScammersView({ children, ...props }) {
|
|
|
42
42
|
const config = {
|
|
43
43
|
portal: "admin",
|
|
44
44
|
title: "Scammers",
|
|
45
|
-
|
|
45
|
+
// buildScammerSearchTxt indexes all four identity arrays plus scamType.
|
|
46
|
+
// They are stored PLAINTEXT on purpose — a victim googles a UPI id before
|
|
47
|
+
// paying, so SEO is the feature — which is why this collection can have a
|
|
48
|
+
// partial-match box where users/reviews/payouts cannot.
|
|
49
|
+
//
|
|
50
|
+
// The old box promised the same three fields but ran `displayNames@=q`,
|
|
51
|
+
// i.e. array-contains on a WHOLE element: "Vikram" matched nothing
|
|
52
|
+
// against ["Vikram M", "Vikram Mehta"], and phone/UPI were never
|
|
53
|
+
// searched at all.
|
|
54
|
+
search: {
|
|
55
|
+
placeholder: "Search by name, phone, UPI ID or email…",
|
|
56
|
+
mode: "partial",
|
|
57
|
+
fields: ["displayNames", "phones", "upiIds", "emails", "scamType"],
|
|
58
|
+
commit: "debounce",
|
|
59
|
+
},
|
|
46
60
|
emptyLabel: "No scammer profiles found",
|
|
47
61
|
filterKeys: ["status"],
|
|
48
62
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
@@ -105,8 +105,29 @@ export interface ListingViewConfig<TResponse, TRow extends {
|
|
|
105
105
|
placeholder: string;
|
|
106
106
|
/** `exact` for a field resolved through an HMAC blind index. */
|
|
107
107
|
mode?: "partial" | "exact";
|
|
108
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Fields the endpoint actually matches on.
|
|
110
|
+
*
|
|
111
|
+
* Not decoration: this is what lets the audit check that the placeholder
|
|
112
|
+
* does not promise a match the corpus cannot make. A box reading "search
|
|
113
|
+
* by buyer" over an index built from productTitle/storeName/trackingNumber
|
|
114
|
+
* is the same lie as a box the endpoint ignores entirely — just harder to
|
|
115
|
+
* notice, because it returns a plausible empty list instead of nothing.
|
|
116
|
+
*/
|
|
109
117
|
fields?: readonly string[];
|
|
118
|
+
/**
|
|
119
|
+
* When the term reaches the URL. Defaults to `"enter"`, which is the
|
|
120
|
+
* behaviour every existing box already had.
|
|
121
|
+
*
|
|
122
|
+
* 🛑 `"debounce"` is invalid with `mode: "exact"` and the audit blocks the
|
|
123
|
+
* pairing. An exact search resolves an HMAC blind index or a whole-value
|
|
124
|
+
* equality, so every keystroke before the last is a query GUARANTEED to
|
|
125
|
+
* match nothing — the user watches "no results" while typing a value that
|
|
126
|
+
* is in fact present, and each of those round trips is a billed read.
|
|
127
|
+
*/
|
|
128
|
+
commit?: "enter" | "debounce";
|
|
129
|
+
/** Debounce delay in ms. Ignored unless `commit: "debounce"`. */
|
|
130
|
+
debounceMs?: number;
|
|
110
131
|
};
|
|
111
132
|
emptyLabel?: string;
|
|
112
133
|
sortOptions: ListingSortOption[];
|
|
@@ -26,6 +26,13 @@ export function DataListingView({ config, }) {
|
|
|
26
26
|
const listing = useAdminListing({
|
|
27
27
|
...config,
|
|
28
28
|
initialView: effectiveInitialView,
|
|
29
|
+
// Mapped explicitly: `...config` cannot reach these, they live one level
|
|
30
|
+
// down under `search`. An exact-match search is never debounced — every
|
|
31
|
+
// keystroke before the last is a query guaranteed to match nothing — so
|
|
32
|
+
// the mode forces "enter" regardless of what the view asked for, and the
|
|
33
|
+
// audit blocks the pairing at author time rather than relying on this.
|
|
34
|
+
searchCommit: config.search?.mode === "exact" ? "enter" : config.search?.commit,
|
|
35
|
+
searchDebounceMs: config.search?.debounceMs,
|
|
29
36
|
});
|
|
30
37
|
const { view, setView, table, panel, searchInput, setSearchInput, commitSearch, filterOpen, setFilterOpen, openFilters, applyFilters, clearFilters, pendingFilters, setPendingFilters, activeFilterCount, hasActiveState, resetAll, rows, isLoading, errorMessage, currentPage, totalPages, selection, pageSize, setPageSize, } = listing;
|
|
31
38
|
const selectionContext = {
|
|
@@ -20,6 +20,15 @@ export interface AdminListingConfig<TResponse, TRow extends {
|
|
|
20
20
|
* a non-"All" filter state (e.g. hide inactive rows) on first load, while
|
|
21
21
|
* "Clear filters" (which writes an explicit "") still shows everything. */
|
|
22
22
|
filterDefaults?: Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* When a typed term reaches the URL. `"enter"` (the default) is what every
|
|
25
|
+
* box already did — commit on Enter or the button, never on keystroke.
|
|
26
|
+
*
|
|
27
|
+
* Passed down from `ListingViewConfig.search.commit`; see the 🛑 there for
|
|
28
|
+
* why `"debounce"` is invalid with `mode: "exact"`.
|
|
29
|
+
*/
|
|
30
|
+
searchCommit?: "enter" | "debounce";
|
|
31
|
+
searchDebounceMs?: number;
|
|
23
32
|
}
|
|
24
33
|
export declare function useAdminListing<TResponse, TRow extends {
|
|
25
34
|
id: string;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useState, useCallback } from "react";
|
|
2
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
3
3
|
import { useUrlTable } from "../../../react/hooks/useUrlTable";
|
|
4
4
|
import { useBulkSelection } from "../../../react/hooks/useBulkSelection";
|
|
5
5
|
import { usePanelUrlSync } from "../../../react/hooks/use-panel-url-sync";
|
|
6
6
|
import { useDataViewMode } from "../../account/hooks/useDataViewMode";
|
|
7
7
|
import { useAdminListingData } from "./useAdminListingData";
|
|
8
8
|
export function useAdminListing(config) {
|
|
9
|
-
const { filterKeys, defaultSort, pageSize: defaultPageSize = 25, queryKey, endpoint, mapRows, getTotal, buildFilters, buildExtraParams, filterDefaults = {}, } = config;
|
|
9
|
+
const { filterKeys, defaultSort, pageSize: defaultPageSize = 25, queryKey, endpoint, mapRows, getTotal, buildFilters, buildExtraParams, filterDefaults = {}, searchCommit = "enter", searchDebounceMs = 350, } = config;
|
|
10
10
|
// Persisted, viewport-aware view-mode: below 768px defaults to "list"
|
|
11
11
|
// (AdminViewCards' one-full-width-card-per-row layout) unless the caller
|
|
12
12
|
// explicitly pins hideTableView (grid-only views like coupons); the user's
|
|
@@ -53,6 +53,34 @@ export function useAdminListing(config) {
|
|
|
53
53
|
const commitSearch = useCallback(() => {
|
|
54
54
|
table.set("q", searchInput.trim());
|
|
55
55
|
}, [searchInput, table]);
|
|
56
|
+
// Debounced commit, opt-in per view.
|
|
57
|
+
//
|
|
58
|
+
// Two things here are load-bearing:
|
|
59
|
+
//
|
|
60
|
+
// 1. It compares against what is ALREADY in the URL and skips a no-op.
|
|
61
|
+
// `table.set` issues a router.replace, and replacing the URL with the
|
|
62
|
+
// value it already holds re-runs the query for nothing — on mount, on
|
|
63
|
+
// every unrelated re-render, and on the render caused by the previous
|
|
64
|
+
// commit. That is the double-navigation shape of Root Cause #13.
|
|
65
|
+
//
|
|
66
|
+
// 2. The timer is cleared on every keystroke AND on unmount, so a term is
|
|
67
|
+
// never committed by a view the user has already navigated away from.
|
|
68
|
+
const debounceRef = useRef(null);
|
|
69
|
+
const committed = table.get("q") ?? "";
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (searchCommit !== "debounce")
|
|
72
|
+
return;
|
|
73
|
+
const next = searchInput.trim();
|
|
74
|
+
if (next === committed)
|
|
75
|
+
return;
|
|
76
|
+
if (debounceRef.current)
|
|
77
|
+
clearTimeout(debounceRef.current);
|
|
78
|
+
debounceRef.current = setTimeout(() => table.set("q", next), searchDebounceMs);
|
|
79
|
+
return () => {
|
|
80
|
+
if (debounceRef.current)
|
|
81
|
+
clearTimeout(debounceRef.current);
|
|
82
|
+
};
|
|
83
|
+
}, [searchInput, committed, searchCommit, searchDebounceMs, table]);
|
|
56
84
|
const activeFilterCount = filterKeys.filter((k) => !!table.get(k)).length;
|
|
57
85
|
const hasActiveState = !!table.get("q") || table.get("sort") !== defaultSort || activeFilterCount > 0;
|
|
58
86
|
const currentFilterState = Object.fromEntries(filterKeys.map((k) => [k, table.get(k)]));
|
|
@@ -6,7 +6,28 @@ export declare class UserRepository extends BaseRepository<UserDocument> {
|
|
|
6
6
|
static readonly SIEVE_FIELDS: FirebaseSieveFields;
|
|
7
7
|
constructor();
|
|
8
8
|
private decryptUser;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Declared, not hand-rolled.
|
|
11
|
+
*
|
|
12
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
13
|
+
* `encryptUserData` did — encrypt the listed fields, then add the mapped blind
|
|
14
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
15
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
16
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
17
|
+
* encryption lived in a private method only three call sites remembered to
|
|
18
|
+
* call.
|
|
19
|
+
*/
|
|
20
|
+
protected piiFields: readonly ["email", "phoneNumber", "googleLinkedEmail"];
|
|
21
|
+
protected piiIndexMap: Record<string, string>;
|
|
22
|
+
/**
|
|
23
|
+
* The base hook, plus the nested objects it cannot see.
|
|
24
|
+
*
|
|
25
|
+
* `piiFields` is a flat list of top-level names, so a nested blob has to be
|
|
26
|
+
* encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
|
|
27
|
+
* encryption path with one extra step on top, rather than two parallel paths
|
|
28
|
+
* that have to agree — which is what the hand-rolled override was.
|
|
29
|
+
*/
|
|
30
|
+
protected applyWriteHooks<D extends object>(data: D): D;
|
|
10
31
|
protected mapDoc<D = UserDocument>(snap: DocumentSnapshot): D;
|
|
11
32
|
createWithId(id: string, data: Partial<UserDocument>): Promise<UserDocument>;
|
|
12
33
|
create(input: Omit<UserDocument, "id" | "createdAt" | "updatedAt">): Promise<UserDocument>;
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { normalizeError } from "../../../errors/normalize";
|
|
2
2
|
import { DatabaseError } from "../../../errors";
|
|
3
3
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
4
|
-
import { USER_PII_FIELDS, USER_PII_INDEX_MAP,
|
|
4
|
+
import { USER_PII_FIELDS, USER_PII_INDEX_MAP, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptShippingConfig, piiBlindIndex, } from "../../../security";
|
|
5
5
|
import { USER_COLLECTION, USER_FIELDS, } from "../schemas";
|
|
6
6
|
export class UserRepository extends BaseRepository {
|
|
7
7
|
constructor() {
|
|
8
8
|
super(USER_COLLECTION);
|
|
9
|
+
/**
|
|
10
|
+
* Declared, not hand-rolled.
|
|
11
|
+
*
|
|
12
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
13
|
+
* `encryptUserData` did — encrypt the listed fields, then add the mapped blind
|
|
14
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
15
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
16
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
17
|
+
* encryption lived in a private method only three call sites remembered to
|
|
18
|
+
* call.
|
|
19
|
+
*/
|
|
20
|
+
this.piiFields = USER_PII_FIELDS;
|
|
21
|
+
this.piiIndexMap = USER_PII_INDEX_MAP;
|
|
9
22
|
}
|
|
10
23
|
decryptUser(doc) {
|
|
11
24
|
const decrypted = decryptPiiFields(doc, [...USER_PII_FIELDS]);
|
|
@@ -17,32 +30,22 @@ export class UserRepository extends BaseRepository {
|
|
|
17
30
|
}
|
|
18
31
|
return decrypted;
|
|
19
32
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// `piiIndicesFor` applies USER_PII_INDEX_MAP's real names and, unlike
|
|
33
|
-
// `addPiiIndices`, carries no plaintext back over the ciphertext.
|
|
34
|
-
const encrypted = {
|
|
35
|
-
...encryptPiiFields(data, [...USER_PII_FIELDS]),
|
|
36
|
-
...piiIndicesFor(data, USER_PII_INDEX_MAP),
|
|
37
|
-
};
|
|
38
|
-
const access = encrypted;
|
|
39
|
-
if (access.payoutDetails) {
|
|
33
|
+
/**
|
|
34
|
+
* The base hook, plus the nested objects it cannot see.
|
|
35
|
+
*
|
|
36
|
+
* `piiFields` is a flat list of top-level names, so a nested blob has to be
|
|
37
|
+
* encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
|
|
38
|
+
* encryption path with one extra step on top, rather than two parallel paths
|
|
39
|
+
* that have to agree — which is what the hand-rolled override was.
|
|
40
|
+
*/
|
|
41
|
+
applyWriteHooks(data) {
|
|
42
|
+
const out = super.applyWriteHooks(data);
|
|
43
|
+
const access = out;
|
|
44
|
+
if (access.payoutDetails)
|
|
40
45
|
access.payoutDetails = encryptPayoutDetails(access.payoutDetails);
|
|
41
|
-
|
|
42
|
-
if (access.shippingConfig) {
|
|
46
|
+
if (access.shippingConfig)
|
|
43
47
|
access.shippingConfig = encryptShippingConfig(access.shippingConfig);
|
|
44
|
-
|
|
45
|
-
return encrypted;
|
|
48
|
+
return out;
|
|
46
49
|
}
|
|
47
50
|
mapDoc(snap) {
|
|
48
51
|
const raw = super.mapDoc(snap);
|
|
@@ -57,7 +60,7 @@ export class UserRepository extends BaseRepository {
|
|
|
57
60
|
// check with no error anywhere (found via a live RBAC bug where an
|
|
58
61
|
// admin account created this way couldn't access /admin).
|
|
59
62
|
const withUid = { uid: id, ...data };
|
|
60
|
-
const encrypted = this.
|
|
63
|
+
const encrypted = this.applyWriteHooks(withUid);
|
|
61
64
|
return super.createWithId(id, encrypted);
|
|
62
65
|
}
|
|
63
66
|
async create(input) {
|
|
@@ -78,7 +81,7 @@ export class UserRepository extends BaseRepository {
|
|
|
78
81
|
createdAt: new Date(),
|
|
79
82
|
updatedAt: new Date(),
|
|
80
83
|
};
|
|
81
|
-
const encrypted = this.
|
|
84
|
+
const encrypted = this.applyWriteHooks(userData);
|
|
82
85
|
await this.db
|
|
83
86
|
.collection(this.collection)
|
|
84
87
|
.doc(id)
|
|
@@ -155,7 +158,7 @@ export class UserRepository extends BaseRepository {
|
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
async update(uid, data) {
|
|
158
|
-
const encrypted = this.
|
|
161
|
+
const encrypted = this.applyWriteHooks(data);
|
|
159
162
|
return super.update(uid, encrypted);
|
|
160
163
|
}
|
|
161
164
|
async markEmailAsVerified(uid) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type DocumentReference } from "firebase-admin/firestore";
|
|
2
|
-
import type { DocumentSnapshot } from "../../../providers/db-firebase";
|
|
3
2
|
import { BaseRepository, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
4
3
|
import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type AddGroupLineInput, type UpdateCartItemInput, type UpdateCartGroupMembersInput } from "../schemas";
|
|
5
4
|
export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
@@ -7,7 +6,21 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
|
7
6
|
constructor();
|
|
8
7
|
/** Admin-facing paginated list of carts. */
|
|
9
8
|
list(model: SieveModel): Promise<FirebaseSieveResult<CartDocument>>;
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* `mapDoc` is deliberately NOT overridden.
|
|
11
|
+
*
|
|
12
|
+
* It used to decrypt `items[].storeName` on every read — and nothing has
|
|
13
|
+
* ever encrypted that field. `CartRepository` declares no `piiFields`, and
|
|
14
|
+
* `storeName` appears nowhere in pii-schemas.ts. `decryptPii` passes a
|
|
15
|
+
* non-`enc:v1:` string straight through, so the call was a no-op on every
|
|
16
|
+
* document while reading as protection — the most expensive kind of dead
|
|
17
|
+
* code, because it answers the question "is this handled?" wrongly.
|
|
18
|
+
*
|
|
19
|
+
* A store name is not PII: it is the seller's public shopfront name, shown
|
|
20
|
+
* on every listing. If a genuinely private field is added to a cart line,
|
|
21
|
+
* declare it in `piiFields` so `applyWriteHooks` encrypts it on all nine
|
|
22
|
+
* write paths — do not reintroduce a decrypt with no matching encrypt.
|
|
23
|
+
*/
|
|
11
24
|
findByUserId(userId: string): Promise<CartDocument | null>;
|
|
12
25
|
getOrCreate(userId: string): Promise<CartDocument>;
|
|
13
26
|
createEmpty(userId: string): Promise<CartDocument>;
|
|
@@ -6,7 +6,6 @@ import { normalizeError } from "../../../errors/normalize";
|
|
|
6
6
|
const ERR_CART_ITEM_NOT_FOUND = "Cart item not found";
|
|
7
7
|
const ERR_CART_ITEM_LOCKED = "This item requires payment and cannot be removed or modified.";
|
|
8
8
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
9
|
-
import { decryptPii } from "../../../security";
|
|
10
9
|
import { CART_COLLECTION, } from "../schemas";
|
|
11
10
|
export class CartRepository extends BaseRepository {
|
|
12
11
|
constructor() {
|
|
@@ -16,18 +15,21 @@ export class CartRepository extends BaseRepository {
|
|
|
16
15
|
async list(model) {
|
|
17
16
|
return this.sieveQuery(model, CartRepository.SIEVE_FIELDS);
|
|
18
17
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `mapDoc` is deliberately NOT overridden.
|
|
20
|
+
*
|
|
21
|
+
* It used to decrypt `items[].storeName` on every read — and nothing has
|
|
22
|
+
* ever encrypted that field. `CartRepository` declares no `piiFields`, and
|
|
23
|
+
* `storeName` appears nowhere in pii-schemas.ts. `decryptPii` passes a
|
|
24
|
+
* non-`enc:v1:` string straight through, so the call was a no-op on every
|
|
25
|
+
* document while reading as protection — the most expensive kind of dead
|
|
26
|
+
* code, because it answers the question "is this handled?" wrongly.
|
|
27
|
+
*
|
|
28
|
+
* A store name is not PII: it is the seller's public shopfront name, shown
|
|
29
|
+
* on every listing. If a genuinely private field is added to a cart line,
|
|
30
|
+
* declare it in `piiFields` so `applyWriteHooks` encrypts it on all nine
|
|
31
|
+
* write paths — do not reintroduce a decrypt with no matching encrypt.
|
|
32
|
+
*/
|
|
31
33
|
async findByUserId(userId) {
|
|
32
34
|
return this.findById(userId);
|
|
33
35
|
}
|
|
@@ -34,7 +34,13 @@ export function AdminEventsView({ children, ...props }) {
|
|
|
34
34
|
portal: "admin",
|
|
35
35
|
title: "Events",
|
|
36
36
|
// buildEventSearchTxt indexes title + description + type + tags.
|
|
37
|
-
|
|
37
|
+
// buildEventSearchTxt indexes title + description + type + tags.
|
|
38
|
+
search: {
|
|
39
|
+
placeholder: "Search by title, description, type or tag…",
|
|
40
|
+
mode: "partial",
|
|
41
|
+
fields: ["title", "description", "type", "tags"],
|
|
42
|
+
commit: "debounce",
|
|
43
|
+
},
|
|
38
44
|
emptyLabel: "No events found",
|
|
39
45
|
filterKeys: ["status", "type"],
|
|
40
46
|
defaultSort: sortBy("startsAt", "DESC"),
|
|
@@ -27,7 +27,18 @@ export interface OrderWriteContext {
|
|
|
27
27
|
declare class OrderRepository extends BaseRepository<OrderDocument> {
|
|
28
28
|
constructor();
|
|
29
29
|
private decryptOrder;
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Declared, not hand-rolled.
|
|
32
|
+
*
|
|
33
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
34
|
+
* `encryptOrderData` did — encrypt the listed fields, then add the mapped blind
|
|
35
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
36
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
37
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
38
|
+
* encryption lived in a private method only three call sites remembered to
|
|
39
|
+
* call.
|
|
40
|
+
*/
|
|
41
|
+
protected piiFields: readonly ["userName", "userEmail", "sellerEmail"];
|
|
31
42
|
protected mapDoc<D = OrderDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
|
|
32
43
|
create(input: OrderCreateInput): Promise<OrderDocument>;
|
|
33
44
|
findByUser(userId: string): Promise<OrderDocument[]>;
|
|
@@ -3,7 +3,7 @@ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../uti
|
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
4
|
import { DatabaseError, NotFoundError } from "../../../errors";
|
|
5
5
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
6
|
-
import { decryptPiiFields,
|
|
6
|
+
import { decryptPiiFields, ORDER_PII_FIELDS, } from "../../../security";
|
|
7
7
|
import { serverTimestamp, arrayUnion } from "../../../contracts/field-ops";
|
|
8
8
|
import { createOrderId, ORDER_COLLECTION, ORDER_TRACKED_FIELDS, OrderStatusValues, } from "../schemas";
|
|
9
9
|
import { withHistory } from "../../../_internal/shared/history/index";
|
|
@@ -24,15 +24,24 @@ const ACTIVE_ALLOWANCE_STATUSES = [
|
|
|
24
24
|
class OrderRepository extends BaseRepository {
|
|
25
25
|
constructor() {
|
|
26
26
|
super(ORDER_COLLECTION);
|
|
27
|
+
/**
|
|
28
|
+
* Declared, not hand-rolled.
|
|
29
|
+
*
|
|
30
|
+
* `BaseRepository.applyWriteHooks` already does exactly what the private
|
|
31
|
+
* `encryptOrderData` did — encrypt the listed fields, then add the mapped blind
|
|
32
|
+
* indices derived from the PLAINTEXT source. Declaring the fields means all
|
|
33
|
+
* nine mutating methods get it, including the `createWithId` / `*InTx` /
|
|
34
|
+
* `*InBatch` family that inherited unhooked base implementations while the
|
|
35
|
+
* encryption lived in a private method only three call sites remembered to
|
|
36
|
+
* call.
|
|
37
|
+
*/
|
|
38
|
+
this.piiFields = ORDER_PII_FIELDS;
|
|
27
39
|
}
|
|
28
40
|
decryptOrder(doc) {
|
|
29
41
|
return decryptPiiFields(doc, [
|
|
30
42
|
...ORDER_PII_FIELDS,
|
|
31
43
|
]);
|
|
32
44
|
}
|
|
33
|
-
encryptOrderData(data) {
|
|
34
|
-
return encryptPiiFields(data, [...ORDER_PII_FIELDS]);
|
|
35
|
-
}
|
|
36
45
|
mapDoc(snap) {
|
|
37
46
|
const raw = super.mapDoc(snap);
|
|
38
47
|
return this.decryptOrder(raw);
|
|
@@ -46,7 +55,7 @@ class OrderRepository extends BaseRepository {
|
|
|
46
55
|
createdAt: new Date(),
|
|
47
56
|
updatedAt: new Date(),
|
|
48
57
|
};
|
|
49
|
-
const encrypted = this.
|
|
58
|
+
const encrypted = this.applyWriteHooks(orderData);
|
|
50
59
|
await this.db
|
|
51
60
|
.collection(this.collection)
|
|
52
61
|
.doc(id)
|