@mohasinac/appkit 4.30.13 → 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/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/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/schemas/registry.d.ts +8 -8
- 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
|
@@ -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"),
|
|
@@ -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"),
|
|
@@ -24,7 +24,14 @@ export function SellerOffersView({ children, onAcceptOffer, onRejectOffer, onCou
|
|
|
24
24
|
title: "Offers",
|
|
25
25
|
// buildOfferSearchTxt indexes productTitle + productSlug + storeName — never
|
|
26
26
|
// the buyer's name or email.
|
|
27
|
-
|
|
27
|
+
// buildOfferSearchTxt indexes productTitle + productSlug + storeName —
|
|
28
|
+
// never the buyer's name or email.
|
|
29
|
+
search: {
|
|
30
|
+
placeholder: "Search by product or store…",
|
|
31
|
+
mode: "partial",
|
|
32
|
+
fields: ["productTitle", "productSlug", "storeName"],
|
|
33
|
+
commit: "debounce",
|
|
34
|
+
},
|
|
28
35
|
emptyLabel: "No offers received",
|
|
29
36
|
filterKeys: ["status"],
|
|
30
37
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
@@ -297,7 +297,14 @@ export function SellerOrdersView({ orderDetailApiBase = SELLER_ENDPOINTS.ORDERS,
|
|
|
297
297
|
// buildOrderSearchTxt indexes productTitle, storeName and trackingNumber.
|
|
298
298
|
// It does NOT index the buyer's name, email or address — those are PII,
|
|
299
299
|
// and the placeholder must not promise a match it will never make.
|
|
300
|
-
|
|
300
|
+
// buildOrderSearchTxt indexes productTitle, storeName and trackingNumber.
|
|
301
|
+
// NOT the buyer's name, email or address — those are PII (D1).
|
|
302
|
+
search: {
|
|
303
|
+
placeholder: "Search by product, store or tracking number…",
|
|
304
|
+
mode: "partial",
|
|
305
|
+
fields: ["productTitle", "storeName", "trackingNumber"],
|
|
306
|
+
commit: "debounce",
|
|
307
|
+
},
|
|
301
308
|
emptyLabel: "No orders yet",
|
|
302
309
|
filterKeys: ["status"],
|
|
303
310
|
defaultSort: DEFAULT_SORT,
|
|
@@ -3281,11 +3281,11 @@ export declare const SCHEMAS: {
|
|
|
3281
3281
|
handle: import("zod").ZodString;
|
|
3282
3282
|
url: import("zod").ZodOptional<import("zod").ZodString>;
|
|
3283
3283
|
}, "strip", import("zod").ZodTypeAny, {
|
|
3284
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
3284
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
3285
3285
|
handle: string;
|
|
3286
3286
|
url?: string | undefined;
|
|
3287
3287
|
}, {
|
|
3288
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
3288
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
3289
3289
|
handle: string;
|
|
3290
3290
|
url?: string | undefined;
|
|
3291
3291
|
}>, "many">;
|
|
@@ -3330,7 +3330,7 @@ export declare const SCHEMAS: {
|
|
|
3330
3330
|
scamType: string;
|
|
3331
3331
|
reportedBy: string;
|
|
3332
3332
|
reportedByAnon: boolean;
|
|
3333
|
-
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "
|
|
3333
|
+
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
|
|
3334
3334
|
evidence: string[];
|
|
3335
3335
|
phones: string[];
|
|
3336
3336
|
upiIds: string[];
|
|
@@ -3339,7 +3339,7 @@ export declare const SCHEMAS: {
|
|
|
3339
3339
|
incidentCount: number;
|
|
3340
3340
|
seoSlug: string;
|
|
3341
3341
|
socialMedia: {
|
|
3342
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
3342
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
3343
3343
|
handle: string;
|
|
3344
3344
|
url?: string | undefined;
|
|
3345
3345
|
}[];
|
|
@@ -3370,7 +3370,7 @@ export declare const SCHEMAS: {
|
|
|
3370
3370
|
scamType: string;
|
|
3371
3371
|
reportedBy: string;
|
|
3372
3372
|
reportedByAnon: boolean;
|
|
3373
|
-
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "
|
|
3373
|
+
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
|
|
3374
3374
|
evidence: string[];
|
|
3375
3375
|
phones: string[];
|
|
3376
3376
|
upiIds: string[];
|
|
@@ -3379,7 +3379,7 @@ export declare const SCHEMAS: {
|
|
|
3379
3379
|
incidentCount: number;
|
|
3380
3380
|
seoSlug: string;
|
|
3381
3381
|
socialMedia: {
|
|
3382
|
-
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "
|
|
3382
|
+
platform: "other" | "whatsapp" | "youtube" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "linkedin";
|
|
3383
3383
|
handle: string;
|
|
3384
3384
|
url?: string | undefined;
|
|
3385
3385
|
}[];
|
|
@@ -3438,7 +3438,7 @@ export declare const SCHEMAS: {
|
|
|
3438
3438
|
reportedBy: string;
|
|
3439
3439
|
scammerId: string;
|
|
3440
3440
|
reportedByAnon: boolean;
|
|
3441
|
-
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "
|
|
3441
|
+
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
|
|
3442
3442
|
evidence: string[];
|
|
3443
3443
|
verifiedBy?: string | undefined;
|
|
3444
3444
|
amountLost?: number | undefined;
|
|
@@ -3461,7 +3461,7 @@ export declare const SCHEMAS: {
|
|
|
3461
3461
|
reportedBy: string;
|
|
3462
3462
|
scammerId: string;
|
|
3463
3463
|
reportedByAnon: boolean;
|
|
3464
|
-
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "
|
|
3464
|
+
scamPlatform: "other" | "whatsapp" | "letitrip" | "instagram" | "facebook" | "telegram" | "twitter" | "olx" | "facebook_marketplace" | "in_person" | "phone_call";
|
|
3465
3465
|
evidence: string[];
|
|
3466
3466
|
verifiedBy?: string | undefined;
|
|
3467
3467
|
amountLost?: number | undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StoreDocument } from "../../features/stores/schemas/firestore";
|
|
2
|
+
import type { ScammerDocument } from "../../features/scams/schemas/firestore";
|
|
2
3
|
import type { EventDocument } from "../../features/events/schemas/firestore";
|
|
3
4
|
import type { BlogPostDocument } from "../../features/blog/schemas/firestore";
|
|
4
5
|
import type { ReviewDocument } from "../../features/reviews/schemas/firestore";
|
|
@@ -12,3 +13,4 @@ export declare function withReviewSearchTxt<T extends Partial<ReviewDocument>>(p
|
|
|
12
13
|
export declare function withOrderSearchTxt<T extends Partial<OrderDocument>>(p: T): T;
|
|
13
14
|
export declare function withCouponSearchTxt<T extends Partial<CouponDocument>>(p: T): T;
|
|
14
15
|
export declare function withOfferSearchTxt<T extends Partial<OfferDocument>>(p: T): T;
|
|
16
|
+
export declare function withScammerSearchTxt<T extends Partial<ScammerDocument>>(p: T): T;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildStoreSearchTxt, buildEventSearchTxt, buildBlogSearchTxt, buildReviewSearchTxt, buildOrderSearchTxt, buildCouponSearchTxt, buildOfferSearchTxt, } from "../../utils/search-txt-builders";
|
|
1
|
+
import { buildStoreSearchTxt, buildEventSearchTxt, buildBlogSearchTxt, buildReviewSearchTxt, buildOrderSearchTxt, buildCouponSearchTxt, buildOfferSearchTxt, buildScammerSearchTxt, } from "../../utils/search-txt-builders";
|
|
2
2
|
export function withStoreSearchTxt(p) {
|
|
3
3
|
return { ...p, searchTxt: buildStoreSearchTxt(p) };
|
|
4
4
|
}
|
|
@@ -20,3 +20,6 @@ export function withCouponSearchTxt(p) {
|
|
|
20
20
|
export function withOfferSearchTxt(p) {
|
|
21
21
|
return { ...p, searchTxt: buildOfferSearchTxt(p) };
|
|
22
22
|
}
|
|
23
|
+
export function withScammerSearchTxt(p) {
|
|
24
|
+
return { ...p, searchTxt: buildScammerSearchTxt(p) };
|
|
25
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* @tag consumers:seed/index.ts,seed/runner.ts
|
|
18
18
|
* @tag sideEffects:none
|
|
19
19
|
*/
|
|
20
|
+
import { withScammerSearchTxt } from "./_helpers/search-txt-wrappers";
|
|
20
21
|
import { SCAMMER_FIELDS } from "../constants/field-names";
|
|
21
22
|
import { seedExtMedia } from "./_helpers/media";
|
|
22
23
|
const NOW = new Date("2026-05-10T00:00:00.000Z");
|
|
@@ -32,7 +33,12 @@ const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
|
|
|
32
33
|
function entry(at, actorRole, trigger, changes, extra) {
|
|
33
34
|
return { at, actorRole, trigger, changes, ...extra };
|
|
34
35
|
}
|
|
35
|
-
export
|
|
36
|
+
// Annotated HERE, not on the export, so the literal is contextually typed as
|
|
37
|
+
// Partial<ScammerDocument> before `.map` sees it. Without this TS infers a
|
|
38
|
+
// union of the individual object shapes and rejects the wrapper's generic —
|
|
39
|
+
// and the error points at the map call rather than at the field that actually
|
|
40
|
+
// diverged.
|
|
41
|
+
const scammerRows = [
|
|
36
42
|
// ── 1. Verified — advance payment ghost ──────────────────────────────────────
|
|
37
43
|
{
|
|
38
44
|
id: "scammer-fake-lob-seller",
|
|
@@ -224,3 +230,6 @@ export const scammersSeedData = [
|
|
|
224
230
|
updatedAt: daysAgo(9),
|
|
225
231
|
},
|
|
226
232
|
];
|
|
233
|
+
// Derived through the wrapper, never per record — an inline literal is how
|
|
234
|
+
// five product seed files shipped their last fixture with no tokens.
|
|
235
|
+
export const scammersSeedData = scammerRows.map(withScammerSearchTxt);
|