@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.
Files changed (57) hide show
  1. package/dist/errors/degraded.d.ts +52 -0
  2. package/dist/errors/degraded.js +47 -0
  3. package/dist/errors/index.d.ts +1 -0
  4. package/dist/errors/index.js +1 -0
  5. package/dist/features/admin/components/AdminBlogView.js +8 -1
  6. package/dist/features/admin/components/AdminCouponsView.js +8 -1
  7. package/dist/features/admin/components/AdminOffersView.js +9 -1
  8. package/dist/features/admin/components/AdminPayoutsView.js +9 -1
  9. package/dist/features/admin/components/AdminReviewsView.js +14 -1
  10. package/dist/features/admin/components/AdminScammersView.js +15 -1
  11. package/dist/features/admin/components/DataListingView.d.ts +22 -1
  12. package/dist/features/admin/components/DataListingView.js +7 -0
  13. package/dist/features/admin/hooks/useAdminListing.d.ts +9 -0
  14. package/dist/features/admin/hooks/useAdminListing.js +30 -2
  15. package/dist/features/events/components/AdminEventsView.js +7 -1
  16. package/dist/features/events/server.d.ts +0 -1
  17. package/dist/features/events/server.js +0 -1
  18. package/dist/features/faq/index.d.ts +0 -1
  19. package/dist/features/faq/index.js +0 -1
  20. package/dist/features/faq/server.d.ts +0 -1
  21. package/dist/features/faq/server.js +0 -1
  22. package/dist/features/reviews/server.d.ts +0 -1
  23. package/dist/features/reviews/server.js +0 -1
  24. package/dist/features/scams/repository/scammer.repository.d.ts +10 -1
  25. package/dist/features/scams/repository/scammer.repository.js +20 -2
  26. package/dist/features/scams/schemas/firestore.d.ts +10 -0
  27. package/dist/features/scams/schemas/firestore.js +1 -0
  28. package/dist/features/scams/schemas/index.d.ts +10 -10
  29. package/dist/features/seller/components/SellerCouponsView.js +7 -1
  30. package/dist/features/seller/components/SellerOffersView.js +8 -1
  31. package/dist/features/seller/components/SellerOrdersView.js +8 -1
  32. package/dist/features/seller/hooks/useSellerListingData.d.ts +11 -25
  33. package/dist/features/seller/hooks/useSellerListingData.js +0 -38
  34. package/dist/features/stores/server.d.ts +0 -1
  35. package/dist/features/stores/server.js +0 -1
  36. package/dist/index.d.ts +2 -7
  37. package/dist/index.js +3 -13
  38. package/dist/providers/db-firebase/base.js +7 -11
  39. package/dist/schemas/registry.d.ts +8 -8
  40. package/dist/security/index.d.ts +1 -1
  41. package/dist/security/index.js +1 -1
  42. package/dist/security/pii-encrypt.d.ts +0 -10
  43. package/dist/security/pii-encrypt.js +0 -38
  44. package/dist/seed/_helpers/search-txt-wrappers.d.ts +2 -0
  45. package/dist/seed/_helpers/search-txt-wrappers.js +4 -1
  46. package/dist/seed/scammers-seed-data.js +10 -1
  47. package/dist/server.d.ts +0 -6
  48. package/dist/server.js +0 -10
  49. package/dist/styles.css +1 -1
  50. package/dist/tailwind-utilities.css +1 -1
  51. package/dist/utils/index.d.ts +0 -1
  52. package/dist/utils/index.js +0 -1
  53. package/dist/utils/search-txt-builders.d.ts +13 -0
  54. package/dist/utils/search-txt-builders.js +20 -0
  55. package/dist/utils/sieve-builder.d.ts +0 -5
  56. package/dist/utils/sieve-builder.js +7 -8
  57. 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
+ };
@@ -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";
@@ -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
- searchPlaceholder: "Search by title, excerpt, category or tag…",
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
- searchPlaceholder: "Search by code, name or description…",
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
- searchPlaceholder: "Search by product or store…",
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
- searchPlaceholder: "Search stores, payout IDs, or order groups",
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
- searchPlaceholder: "Search reviews, products, or seller names",
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
- searchPlaceholder: "Search by name, phone, UPI ID",
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
- /** Fields the endpoint actually matches on. */
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
- searchPlaceholder: "Search by title, description, type or tag…",
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,4 @@
1
1
  export * from "./types";
2
- export * from "./hooks/useFAQs";
3
2
  export * from "./hooks/useFaqList";
4
3
  export * from "./hooks/useFaqVote";
5
4
  export * from "./components";
@@ -1,5 +1,4 @@
1
1
  export * from "./types";
2
- export * from "./hooks/useFAQs";
3
2
  export * from "./hooks/useFaqList";
4
3
  export * from "./hooks/useFaqVote";
5
4
  export * from "./components";
@@ -5,4 +5,3 @@
5
5
  */
6
6
  export * from "./actions";
7
7
  export { FAQsRepository, FirebaseFAQsRepository, faqsRepository, } from "./repository/faqs.repository";
8
- export { GET as faqGET, GET } from "./api/route";
@@ -5,4 +5,3 @@
5
5
  */
6
6
  export * from "./actions";
7
7
  export { FAQsRepository, FirebaseFAQsRepository, faqsRepository, } from "./repository/faqs.repository";
8
- export { GET as faqGET, GET } from "./api/route";
@@ -5,5 +5,4 @@
5
5
  */
6
6
  export * from "./actions";
7
7
  export { ReviewRepository, reviewRepository, } from "./repository/reviews.repository";
8
- export { GET as reviewsGET, GET, POST } from "./api/route";
9
8
  export { reviewItemGET, reviewItemPATCH, reviewItemDELETE, } from "./api/[id]/route";
@@ -5,5 +5,4 @@
5
5
  */
6
6
  export * from "./actions";
7
7
  export { ReviewRepository, reviewRepository, } from "./repository/reviews.repository";
8
- export { GET as reviewsGET, GET, POST } from "./api/route";
9
8
  export { reviewItemGET, reviewItemPATCH, reviewItemDELETE, } 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
- listAll(model: SieveModel): Promise<FirebaseSieveResult<ScammerDocument>>;
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
- async listAll(model) {
22
- return this.sieveQuery(model, ScammerRepository.SIEVE_FIELDS, {
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";
@@ -170,6 +170,7 @@ export const MAX_PENDING_SCAMMER_REPORTS_PER_USER = 5;
170
170
  export const SCAMMER_FIELDS = {
171
171
  ID: "id",
172
172
  SEO_SLUG: "seoSlug",
173
+ SEARCH_TXT: "searchTxt",
173
174
  DISPLAY_NAMES: "displayNames",
174
175
  PHONES: "phones",
175
176
  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" | "twitter" | "linkedin" | "telegram" | "olx";
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" | "twitter" | "linkedin" | "telegram" | "olx";
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" | "twitter" | "linkedin" | "telegram" | "olx";
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" | "twitter" | "linkedin" | "telegram" | "olx";
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" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
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" | "twitter" | "linkedin" | "telegram" | "olx";
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" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
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" | "twitter" | "linkedin" | "telegram" | "olx";
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" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
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" | "twitter" | "telegram" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
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
- searchPlaceholder: "Search by code, name or description…",
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"),