@mohasinac/appkit 4.30.12 → 4.30.15
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/errors/degraded.d.ts +52 -0
- package/dist/errors/degraded.js +47 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +1 -0
- 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/events/components/AdminEventsView.js +7 -1
- package/dist/features/events/server.d.ts +0 -1
- package/dist/features/events/server.js +0 -1
- package/dist/features/faq/index.d.ts +0 -1
- package/dist/features/faq/index.js +0 -1
- package/dist/features/faq/server.d.ts +0 -1
- package/dist/features/faq/server.js +0 -1
- package/dist/features/reviews/server.d.ts +0 -1
- package/dist/features/reviews/server.js +0 -1
- 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/seller/hooks/useSellerListingData.d.ts +11 -25
- package/dist/features/seller/hooks/useSellerListingData.js +0 -38
- package/dist/features/stores/server.d.ts +0 -1
- package/dist/features/stores/server.js +0 -1
- package/dist/index.d.ts +2 -7
- package/dist/index.js +3 -13
- package/dist/providers/db-firebase/base.js +7 -11
- package/dist/schemas/registry.d.ts +8 -8
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-encrypt.d.ts +0 -10
- package/dist/security/pii-encrypt.js +0 -38
- 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/server.d.ts +0 -6
- package/dist/server.js +0 -10
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/index.d.ts +0 -1
- package/dist/utils/index.js +0 -1
- package/dist/utils/search-txt-builders.d.ts +13 -0
- package/dist/utils/search-txt-builders.js +20 -0
- package/dist/utils/sieve-builder.d.ts +0 -5
- package/dist/utils/sieve-builder.js +7 -8
- package/package.json +2 -9
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Degradation that the caller can SEE.
|
|
3
|
+
*
|
|
4
|
+
* Every bug this search/PII migration surfaced returned HTTP 200 with
|
|
5
|
+
* plausible-looking rows. A dropped Sieve clause, a swallowed
|
|
6
|
+
* FAILED_PRECONDITION, a retry with `q` deleted — none of them errored, and a
|
|
7
|
+
* reader had no way to tell a narrowed list from an unnarrowed one. A
|
|
8
|
+
* `serverLogger.warn` does not help: it goes to the operator, and the person
|
|
9
|
+
* being misled is the user.
|
|
10
|
+
*
|
|
11
|
+
* So when a handler cannot serve exactly what was asked for but chooses to
|
|
12
|
+
* serve something anyway, it must say so ON THE RESPONSE.
|
|
13
|
+
*
|
|
14
|
+
* This is not an error channel. A degraded response is a successful response
|
|
15
|
+
* whose shape is honest about a compromise the handler made deliberately —
|
|
16
|
+
* "I applied your filter but ignored your sort", not "something went wrong".
|
|
17
|
+
* A failure that has no defensible partial answer should still throw.
|
|
18
|
+
*/
|
|
19
|
+
/** One thing the handler could not honour, and why. */
|
|
20
|
+
export interface DegradedNotice {
|
|
21
|
+
/** What was dropped, in the caller's vocabulary: "sorts", "filters.status". */
|
|
22
|
+
dropped: string;
|
|
23
|
+
/**
|
|
24
|
+
* Why, in one sentence a UI can show verbatim. Written for the user, not the
|
|
25
|
+
* operator — "Results are not sorted while searching" beats
|
|
26
|
+
* "FAILED_PRECONDITION: composite index required".
|
|
27
|
+
*/
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Attach notices to a response payload.
|
|
32
|
+
*
|
|
33
|
+
* Returns the payload UNCHANGED when nothing degraded, so the common path
|
|
34
|
+
* carries no `degraded: false` noise and a client can treat the field's mere
|
|
35
|
+
* presence as the signal.
|
|
36
|
+
*/
|
|
37
|
+
export declare function withDegraded<T extends object>(payload: T, notices: readonly DegradedNotice[]): T | (T & {
|
|
38
|
+
degraded: readonly DegradedNotice[];
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* The specific compromise three admin search endpoints make.
|
|
42
|
+
*
|
|
43
|
+
* `/api/admin/{users,reviews,payouts}` resolve `q` through an HMAC blind index
|
|
44
|
+
* (`emailIndex==<hash>`) or an exact name match. Firestore needs a composite
|
|
45
|
+
* index for each equality x sort x direction combination, and preserving the
|
|
46
|
+
* caller's sort across all three endpoints was measured at **14** additional
|
|
47
|
+
* indexes — to order a result set that an exact email match bounds at one row.
|
|
48
|
+
*
|
|
49
|
+
* Dropping the sort is therefore the right trade. Dropping it SILENTLY was not:
|
|
50
|
+
* the sort dropdown went on displaying "Oldest" over unsorted rows.
|
|
51
|
+
*/
|
|
52
|
+
export declare const SORT_DROPPED_FOR_EXACT_SEARCH: DegradedNotice;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Degradation that the caller can SEE.
|
|
3
|
+
*
|
|
4
|
+
* Every bug this search/PII migration surfaced returned HTTP 200 with
|
|
5
|
+
* plausible-looking rows. A dropped Sieve clause, a swallowed
|
|
6
|
+
* FAILED_PRECONDITION, a retry with `q` deleted — none of them errored, and a
|
|
7
|
+
* reader had no way to tell a narrowed list from an unnarrowed one. A
|
|
8
|
+
* `serverLogger.warn` does not help: it goes to the operator, and the person
|
|
9
|
+
* being misled is the user.
|
|
10
|
+
*
|
|
11
|
+
* So when a handler cannot serve exactly what was asked for but chooses to
|
|
12
|
+
* serve something anyway, it must say so ON THE RESPONSE.
|
|
13
|
+
*
|
|
14
|
+
* This is not an error channel. A degraded response is a successful response
|
|
15
|
+
* whose shape is honest about a compromise the handler made deliberately —
|
|
16
|
+
* "I applied your filter but ignored your sort", not "something went wrong".
|
|
17
|
+
* A failure that has no defensible partial answer should still throw.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Attach notices to a response payload.
|
|
21
|
+
*
|
|
22
|
+
* Returns the payload UNCHANGED when nothing degraded, so the common path
|
|
23
|
+
* carries no `degraded: false` noise and a client can treat the field's mere
|
|
24
|
+
* presence as the signal.
|
|
25
|
+
*/
|
|
26
|
+
export function withDegraded(payload, notices) {
|
|
27
|
+
if (notices.length === 0)
|
|
28
|
+
return payload;
|
|
29
|
+
return { ...payload, degraded: notices };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The specific compromise three admin search endpoints make.
|
|
33
|
+
*
|
|
34
|
+
* `/api/admin/{users,reviews,payouts}` resolve `q` through an HMAC blind index
|
|
35
|
+
* (`emailIndex==<hash>`) or an exact name match. Firestore needs a composite
|
|
36
|
+
* index for each equality x sort x direction combination, and preserving the
|
|
37
|
+
* caller's sort across all three endpoints was measured at **14** additional
|
|
38
|
+
* indexes — to order a result set that an exact email match bounds at one row.
|
|
39
|
+
*
|
|
40
|
+
* Dropping the sort is therefore the right trade. Dropping it SILENTLY was not:
|
|
41
|
+
* the sort dropdown went on displaying "Oldest" over unsorted rows.
|
|
42
|
+
*/
|
|
43
|
+
export const SORT_DROPPED_FOR_EXACT_SEARCH = {
|
|
44
|
+
dropped: "sorts",
|
|
45
|
+
reason: "Results are not sorted while searching — search matches an exact value, " +
|
|
46
|
+
"so ordering is unavailable for this query.",
|
|
47
|
+
};
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -16,3 +16,4 @@ export { ERROR_DISPLAY_MAP, getErrorDisplay, type ErrorDisplayEntry, } from "./e
|
|
|
16
16
|
export { safeRead, installDegradedReadReporter, type DegradedReadReport, } from "./safe-read";
|
|
17
17
|
export { normalizeError, getErrorMessage, isAuthError, isApiNormalized, isAppNormalized, isFirebaseAuthNormalized, isFirebaseFirestoreNormalized, isFirebaseStorageNormalized, isNativeNormalized, isNetworkNormalized, isUnknownNormalized, isZodNormalized, } from "./normalize";
|
|
18
18
|
export type { NormalizedError, NormalizedApiError, NormalizedAppError, NormalizedFirebaseAuthError, NormalizedFirebaseFirestoreError, NormalizedFirebaseStorageError, NormalizedNativeError, NormalizedNetworkError, NormalizedUnknownThrownValue, NormalizedZodError, } from "./normalize";
|
|
19
|
+
export * from "./degraded";
|
package/dist/errors/index.js
CHANGED
|
@@ -25,3 +25,4 @@ export { safeRead, installDegradedReadReporter, } from "./safe-read";
|
|
|
25
25
|
// `isAuthError` here replaces the legacy keyword matcher at
|
|
26
26
|
// `appkit/src/utils/auth-error.ts` (now a thin re-export shim).
|
|
27
27
|
export { normalizeError, getErrorMessage, isAuthError, isApiNormalized, isAppNormalized, isFirebaseAuthNormalized, isFirebaseFirestoreNormalized, isFirebaseStorageNormalized, isNativeNormalized, isNetworkNormalized, isUnknownNormalized, isZodNormalized, } from "./normalize";
|
|
28
|
+
export * from "./degraded";
|
|
@@ -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)]));
|
|
@@ -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"),
|
|
@@ -6,5 +6,4 @@
|
|
|
6
6
|
export * from "./actions";
|
|
7
7
|
export { EventRepository, EventsRepository, eventRepository, } from "./repository/events.repository";
|
|
8
8
|
export { EventEntryRepository, EventEntriesRepository, eventEntryRepository, } from "./repository/event-entry.repository";
|
|
9
|
-
export { GET as eventsGET, GET } from "./api/route";
|
|
10
9
|
export { GET as eventIdGET } from "./api/[id]/route";
|
|
@@ -6,5 +6,4 @@
|
|
|
6
6
|
export * from "./actions";
|
|
7
7
|
export { EventRepository, EventsRepository, eventRepository, } from "./repository/events.repository";
|
|
8
8
|
export { EventEntryRepository, EventEntriesRepository, eventEntryRepository, } from "./repository/event-entry.repository";
|
|
9
|
-
export { GET as eventsGET, GET } from "./api/route";
|
|
10
9
|
export { GET as eventIdGET } from "./api/[id]/route";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseRepository, parseSieveDateValue, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
2
2
|
import { type HistoryActor } from "../../../_internal/shared/history/index";
|
|
3
|
+
import type { JsonValue } from "../../../schemas/types";
|
|
3
4
|
/** Who/why for a scammer write that lands in `statusHistory`. */
|
|
4
5
|
export interface ScammerWriteContext {
|
|
5
6
|
actor?: HistoryActor;
|
|
@@ -11,6 +12,10 @@ import { type ScammerDocument, type ScammerIncidentDocument, type ScammerComment
|
|
|
11
12
|
declare class ScammerRepository extends BaseRepository<ScammerDocument> {
|
|
12
13
|
constructor();
|
|
13
14
|
static readonly SIEVE_FIELDS: {
|
|
15
|
+
searchTxt: {
|
|
16
|
+
canFilter: boolean;
|
|
17
|
+
canSort: boolean;
|
|
18
|
+
};
|
|
14
19
|
id: {
|
|
15
20
|
canFilter: boolean;
|
|
16
21
|
canSort: boolean;
|
|
@@ -58,7 +63,11 @@ declare class ScammerRepository extends BaseRepository<ScammerDocument> {
|
|
|
58
63
|
};
|
|
59
64
|
};
|
|
60
65
|
listVerified(model: SieveModel): Promise<FirebaseSieveResult<ScammerDocument>>;
|
|
61
|
-
|
|
66
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
67
|
+
protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
|
|
68
|
+
listAll(model: SieveModel, opts?: {
|
|
69
|
+
search?: string;
|
|
70
|
+
}): Promise<FirebaseSieveResult<ScammerDocument>>;
|
|
62
71
|
findBySeoSlug(seoSlug: string): Promise<ScammerDocument | null>;
|
|
63
72
|
create(input: ScammerCreateInput): Promise<ScammerDocument>;
|
|
64
73
|
/**
|
|
@@ -5,6 +5,8 @@ import { serverLogger } from "../../../monitoring/server-logger";
|
|
|
5
5
|
import { increment } from "../../../contracts/field-ops";
|
|
6
6
|
import { withHistory } from "../../../_internal/shared/history/index";
|
|
7
7
|
import { SCAMMER_TRACKED_FIELDS, SCAMMER_HISTORY_PII_FIELDS } from "../schemas/firestore";
|
|
8
|
+
import { buildScammerSearchTxt } from "../../../utils/search-txt-builders";
|
|
9
|
+
import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
|
|
8
10
|
import { SCAMMER_COLLECTION, SCAMMER_FIELDS, SCAMMER_INCIDENTS_SUBCOLLECTION, SCAMMER_COMMENTS_SUBCOLLECTION, DEFAULT_SCAMMER_DATA, } from "../schemas/firestore";
|
|
9
11
|
class ScammerRepository extends BaseRepository {
|
|
10
12
|
constructor() {
|
|
@@ -18,11 +20,26 @@ class ScammerRepository extends BaseRepository {
|
|
|
18
20
|
maxPageSize: 50,
|
|
19
21
|
});
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
24
|
+
buildSearchTxtFor(data) {
|
|
25
|
+
return buildScammerSearchTxt(data);
|
|
26
|
+
}
|
|
27
|
+
async listAll(model, opts) {
|
|
28
|
+
const plan = planSearchTxt(opts?.search);
|
|
29
|
+
// A search that narrowed to no usable term must return NOTHING, not the
|
|
30
|
+
// entire scam registry.
|
|
31
|
+
if (plan.empty)
|
|
32
|
+
return emptySearchResult();
|
|
33
|
+
let baseQuery = this.getCollection();
|
|
34
|
+
if (plan.head) {
|
|
35
|
+
baseQuery = baseQuery.where(SCAMMER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
36
|
+
}
|
|
37
|
+
const result = await this.sieveQuery(model, ScammerRepository.SIEVE_FIELDS, {
|
|
38
|
+
baseQuery,
|
|
23
39
|
defaultPageSize: 50,
|
|
24
40
|
maxPageSize: 200,
|
|
25
41
|
});
|
|
42
|
+
return refineSearchTxt(result, plan.rest);
|
|
26
43
|
}
|
|
27
44
|
async findBySeoSlug(seoSlug) {
|
|
28
45
|
try {
|
|
@@ -203,6 +220,7 @@ class ScammerRepository extends BaseRepository {
|
|
|
203
220
|
}
|
|
204
221
|
}
|
|
205
222
|
ScammerRepository.SIEVE_FIELDS = {
|
|
223
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
206
224
|
id: { canFilter: true, canSort: false },
|
|
207
225
|
status: { canFilter: true, canSort: true },
|
|
208
226
|
scamType: { canFilter: true, canSort: true },
|
|
@@ -105,6 +105,15 @@ export interface ScammerDocument extends BaseDocument {
|
|
|
105
105
|
upiIds: string[];
|
|
106
106
|
/** Email addresses used by the scammer. Stored plaintext for SEO. */
|
|
107
107
|
emails: string[];
|
|
108
|
+
/**
|
|
109
|
+
* Word-prefix n-grams over the identity fields, for `array-contains`
|
|
110
|
+
* search. Derived by `buildScammerSearchTxt` on every write.
|
|
111
|
+
*
|
|
112
|
+
* Optional because documents written before this field existed do not have
|
|
113
|
+
* it. Never filter with an inequality on it — that would exclude exactly
|
|
114
|
+
* those rows, which is the whole pre-existing registry.
|
|
115
|
+
*/
|
|
116
|
+
searchTxt?: string[];
|
|
108
117
|
/** Social media profiles where the scammer operated. */
|
|
109
118
|
socialMedia: ScammerSocialMedia[];
|
|
110
119
|
/** Primary scam type for this profile (most common across incidents). */
|
|
@@ -338,6 +347,7 @@ export type ScammerAdminUpdateInput = Partial<Pick<ScammerDocument, "status" | "
|
|
|
338
347
|
export declare const SCAMMER_FIELDS: {
|
|
339
348
|
readonly ID: "id";
|
|
340
349
|
readonly SEO_SLUG: "seoSlug";
|
|
350
|
+
readonly SEARCH_TXT: "searchTxt";
|
|
341
351
|
readonly DISPLAY_NAMES: "displayNames";
|
|
342
352
|
readonly PHONES: "phones";
|
|
343
353
|
readonly UPI_IDS: "upiIds";
|
|
@@ -10,11 +10,11 @@ export declare const scammerSocialMediaSchema: z.ZodObject<{
|
|
|
10
10
|
handle: z.ZodString;
|
|
11
11
|
url: z.ZodOptional<z.ZodString>;
|
|
12
12
|
}, "strip", z.ZodTypeAny, {
|
|
13
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
13
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
14
14
|
handle: string;
|
|
15
15
|
url?: string | undefined;
|
|
16
16
|
}, {
|
|
17
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
17
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
18
18
|
handle: string;
|
|
19
19
|
url?: string | undefined;
|
|
20
20
|
}>;
|
|
@@ -44,11 +44,11 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
|
|
|
44
44
|
handle: z.ZodString;
|
|
45
45
|
url: z.ZodOptional<z.ZodString>;
|
|
46
46
|
}, "strip", z.ZodTypeAny, {
|
|
47
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
47
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
48
48
|
handle: string;
|
|
49
49
|
url?: string | undefined;
|
|
50
50
|
}, {
|
|
51
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
51
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
52
52
|
handle: string;
|
|
53
53
|
url?: string | undefined;
|
|
54
54
|
}>, "many">;
|
|
@@ -93,7 +93,7 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
|
|
|
93
93
|
scamType: string;
|
|
94
94
|
reportedBy: string;
|
|
95
95
|
reportedByAnon: boolean;
|
|
96
|
-
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "
|
|
96
|
+
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
|
|
97
97
|
evidence: string[];
|
|
98
98
|
phones: string[];
|
|
99
99
|
upiIds: string[];
|
|
@@ -102,7 +102,7 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
|
|
|
102
102
|
incidentCount: number;
|
|
103
103
|
seoSlug: string;
|
|
104
104
|
socialMedia: {
|
|
105
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
105
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
106
106
|
handle: string;
|
|
107
107
|
url?: string | undefined;
|
|
108
108
|
}[];
|
|
@@ -133,7 +133,7 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
|
|
|
133
133
|
scamType: string;
|
|
134
134
|
reportedBy: string;
|
|
135
135
|
reportedByAnon: boolean;
|
|
136
|
-
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "
|
|
136
|
+
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
|
|
137
137
|
evidence: string[];
|
|
138
138
|
phones: string[];
|
|
139
139
|
upiIds: string[];
|
|
@@ -142,7 +142,7 @@ export declare const scammerFirestoreSchema: z.ZodObject<{
|
|
|
142
142
|
incidentCount: number;
|
|
143
143
|
seoSlug: string;
|
|
144
144
|
socialMedia: {
|
|
145
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
145
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
146
146
|
handle: string;
|
|
147
147
|
url?: string | undefined;
|
|
148
148
|
}[];
|
|
@@ -201,7 +201,7 @@ export declare const scammerIncidentFirestoreSchema: z.ZodObject<{
|
|
|
201
201
|
reportedBy: string;
|
|
202
202
|
scammerId: string;
|
|
203
203
|
reportedByAnon: boolean;
|
|
204
|
-
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "
|
|
204
|
+
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
|
|
205
205
|
evidence: string[];
|
|
206
206
|
verifiedBy?: string | undefined;
|
|
207
207
|
amountLost?: number | undefined;
|
|
@@ -224,7 +224,7 @@ export declare const scammerIncidentFirestoreSchema: z.ZodObject<{
|
|
|
224
224
|
reportedBy: string;
|
|
225
225
|
scammerId: string;
|
|
226
226
|
reportedByAnon: boolean;
|
|
227
|
-
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "
|
|
227
|
+
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
|
|
228
228
|
evidence: string[];
|
|
229
229
|
verifiedBy?: string | undefined;
|
|
230
230
|
amountLost?: number | undefined;
|
|
@@ -50,7 +50,13 @@ export function SellerCouponsView({ onCreateClick, onEditClick, onToggle, onDele
|
|
|
50
50
|
portal: "seller",
|
|
51
51
|
title: "Coupons",
|
|
52
52
|
// buildCouponSearchTxt indexes code + name + description — nothing else.
|
|
53
|
-
|
|
53
|
+
// buildCouponSearchTxt indexes exactly these three.
|
|
54
|
+
search: {
|
|
55
|
+
placeholder: "Search by code, name or description…",
|
|
56
|
+
mode: "partial",
|
|
57
|
+
fields: ["code", "name", "description"],
|
|
58
|
+
commit: "debounce",
|
|
59
|
+
},
|
|
54
60
|
emptyLabel: "No coupons found — create your first coupon",
|
|
55
61
|
filterKeys: ["isActive"],
|
|
56
62
|
defaultSort: sortBy("createdAt", "DESC"),
|