@mohasinac/appkit 4.30.2 → 4.30.3
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/blog/actions/blog-actions.d.ts +7 -7
- package/dist/features/blog/repository/blog.repository.d.ts +6 -1
- package/dist/features/blog/repository/blog.repository.js +17 -2
- package/dist/features/blog/schemas/firestore.d.ts +2 -0
- package/dist/features/blog/schemas/firestore.js +2 -0
- package/dist/features/blog/schemas/index.d.ts +7 -7
- package/dist/features/events/repository/events.repository.d.ts +10 -1
- package/dist/features/events/repository/events.repository.js +21 -2
- package/dist/features/events/schemas/firestore.d.ts +2 -0
- package/dist/features/events/schemas/firestore.js +2 -0
- package/dist/features/products/repository/products.repository.js +4 -17
- package/dist/features/reviews/repository/reviews.repository.d.ts +11 -1
- package/dist/features/reviews/repository/reviews.repository.js +25 -2
- package/dist/features/stores/repository/store.repository.d.ts +6 -1
- package/dist/features/stores/repository/store.repository.js +16 -2
- package/dist/features/stores/schemas/firestore.d.ts +2 -0
- package/dist/features/stores/schemas/firestore.js +2 -0
- package/dist/features/stores/schemas/index.d.ts +2 -2
- package/dist/providers/db-firebase/base.repository.d.ts +2 -1
- package/dist/schemas/registry.d.ts +4 -4
- package/dist/seed/_helpers/product-search-txt.js +5 -15
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +4 -16
- package/dist/seed/_helpers/search-txt-wrappers.js +5 -65
- package/dist/utils/search-txt-builders.d.ts +23 -0
- package/dist/utils/search-txt-builders.js +86 -0
- package/dist/utils/search-txt-query.d.ts +42 -0
- package/dist/utils/search-txt-query.js +61 -0
- package/package.json +1 -1
|
@@ -102,9 +102,9 @@ export declare const createBlogPostSchema: z.ZodObject<{
|
|
|
102
102
|
} | null | undefined;
|
|
103
103
|
authorId?: string | undefined;
|
|
104
104
|
authorName?: string | undefined;
|
|
105
|
+
authorAvatar?: string | undefined;
|
|
105
106
|
metaTitle?: string | undefined;
|
|
106
107
|
metaDescription?: string | undefined;
|
|
107
|
-
authorAvatar?: string | undefined;
|
|
108
108
|
}, {
|
|
109
109
|
title: string;
|
|
110
110
|
content: string;
|
|
@@ -130,8 +130,6 @@ export declare const createBlogPostSchema: z.ZodObject<{
|
|
|
130
130
|
alt?: string | undefined;
|
|
131
131
|
thumbnailUrl?: string | undefined;
|
|
132
132
|
}[] | undefined;
|
|
133
|
-
metaTitle?: string | undefined;
|
|
134
|
-
metaDescription?: string | undefined;
|
|
135
133
|
contentImages?: {
|
|
136
134
|
type: "video" | "image" | "file";
|
|
137
135
|
url: string;
|
|
@@ -139,6 +137,8 @@ export declare const createBlogPostSchema: z.ZodObject<{
|
|
|
139
137
|
thumbnailUrl?: string | undefined;
|
|
140
138
|
}[] | undefined;
|
|
141
139
|
authorAvatar?: string | undefined;
|
|
140
|
+
metaTitle?: string | undefined;
|
|
141
|
+
metaDescription?: string | undefined;
|
|
142
142
|
}>;
|
|
143
143
|
export declare const updateBlogPostSchema: z.ZodObject<{
|
|
144
144
|
title: z.ZodOptional<z.ZodString>;
|
|
@@ -235,8 +235,6 @@ export declare const updateBlogPostSchema: z.ZodObject<{
|
|
|
235
235
|
alt?: string | undefined;
|
|
236
236
|
thumbnailUrl?: string | undefined;
|
|
237
237
|
}[] | undefined;
|
|
238
|
-
metaTitle?: string | undefined;
|
|
239
|
-
metaDescription?: string | undefined;
|
|
240
238
|
contentImages?: {
|
|
241
239
|
type: "video" | "image" | "file";
|
|
242
240
|
url: string;
|
|
@@ -244,6 +242,8 @@ export declare const updateBlogPostSchema: z.ZodObject<{
|
|
|
244
242
|
thumbnailUrl?: string | undefined;
|
|
245
243
|
}[] | undefined;
|
|
246
244
|
authorAvatar?: string | undefined;
|
|
245
|
+
metaTitle?: string | undefined;
|
|
246
|
+
metaDescription?: string | undefined;
|
|
247
247
|
}, {
|
|
248
248
|
status?: "draft" | "published" | "archived" | undefined;
|
|
249
249
|
title?: string | undefined;
|
|
@@ -269,8 +269,6 @@ export declare const updateBlogPostSchema: z.ZodObject<{
|
|
|
269
269
|
alt?: string | undefined;
|
|
270
270
|
thumbnailUrl?: string | undefined;
|
|
271
271
|
}[] | undefined;
|
|
272
|
-
metaTitle?: string | undefined;
|
|
273
|
-
metaDescription?: string | undefined;
|
|
274
272
|
contentImages?: {
|
|
275
273
|
type: "video" | "image" | "file";
|
|
276
274
|
url: string;
|
|
@@ -278,6 +276,8 @@ export declare const updateBlogPostSchema: z.ZodObject<{
|
|
|
278
276
|
thumbnailUrl?: string | undefined;
|
|
279
277
|
}[] | undefined;
|
|
280
278
|
authorAvatar?: string | undefined;
|
|
279
|
+
metaTitle?: string | undefined;
|
|
280
|
+
metaDescription?: string | undefined;
|
|
281
281
|
}>;
|
|
282
282
|
export type CreateBlogPostInput = z.infer<typeof createBlogPostSchema>;
|
|
283
283
|
export type UpdateBlogPostInput = z.infer<typeof updateBlogPostSchema>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JsonValue } from "../../../schemas/types";
|
|
1
2
|
import type { FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
|
|
2
3
|
import { BaseRepository, parseSieveDateValue } from "../../../providers/db-firebase";
|
|
3
4
|
import { type BlogPostCreateInput, type BlogPostDocument, type BlogPostUpdateInput } from "../schemas";
|
|
@@ -86,7 +87,11 @@ declare class BlogRepository extends BaseRepository<BlogPostDocument> {
|
|
|
86
87
|
category?: BlogPostCategory;
|
|
87
88
|
featuredOnly?: boolean;
|
|
88
89
|
}, model: SieveModel): Promise<FirebaseSieveResult<BlogPostDocument>>;
|
|
89
|
-
|
|
90
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
91
|
+
protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
|
|
92
|
+
listAll(model: SieveModel, opts?: {
|
|
93
|
+
search?: string;
|
|
94
|
+
}): Promise<FirebaseSieveResult<BlogPostDocument>>;
|
|
90
95
|
}
|
|
91
96
|
declare const blogRepository: BlogRepository;
|
|
92
97
|
export { BlogRepository, blogRepository };
|
|
@@ -4,6 +4,8 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
|
|
|
4
4
|
import { BLOG_POST_FIELDS, BLOG_POSTS_COLLECTION, } from "../schemas";
|
|
5
5
|
import { createBlogPostId } from "../schemas";
|
|
6
6
|
import { increment } from "../../../contracts/field-ops";
|
|
7
|
+
import { buildBlogSearchTxt } from "../../../utils/search-txt-builders";
|
|
8
|
+
import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
|
|
7
9
|
class BlogRepository extends BaseRepository {
|
|
8
10
|
constructor() {
|
|
9
11
|
super(BLOG_POSTS_COLLECTION);
|
|
@@ -153,11 +155,24 @@ class BlogRepository extends BaseRepository {
|
|
|
153
155
|
maxPageSize: 50,
|
|
154
156
|
});
|
|
155
157
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
159
|
+
buildSearchTxtFor(data) {
|
|
160
|
+
return buildBlogSearchTxt(data);
|
|
161
|
+
}
|
|
162
|
+
async listAll(model, opts) {
|
|
163
|
+
const plan = planSearchTxt(opts?.search);
|
|
164
|
+
if (plan.empty)
|
|
165
|
+
return emptySearchResult();
|
|
166
|
+
let baseQuery = this.getCollection();
|
|
167
|
+
if (plan.head) {
|
|
168
|
+
baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
169
|
+
}
|
|
170
|
+
const result = await this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
|
|
171
|
+
baseQuery,
|
|
158
172
|
defaultPageSize: 50,
|
|
159
173
|
maxPageSize: 200,
|
|
160
174
|
});
|
|
175
|
+
return refineSearchTxt(result, plan.rest);
|
|
161
176
|
}
|
|
162
177
|
}
|
|
163
178
|
BlogRepository.SIEVE_FIELDS = {
|
|
@@ -42,6 +42,8 @@ export interface BlogPostDocument extends BaseDocument {
|
|
|
42
42
|
export declare const BLOG_POSTS_COLLECTION: "blogPosts";
|
|
43
43
|
export declare const BLOG_POSTS_INDEXED_FIELDS: readonly ["searchTxt", "status", "publishedAt", "isFeatured", "category", "slug"];
|
|
44
44
|
export declare const BLOG_POST_FIELDS: {
|
|
45
|
+
/** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
|
|
46
|
+
readonly SEARCH_TXT: "searchTxt";
|
|
45
47
|
readonly ID: "id";
|
|
46
48
|
readonly TITLE: "title";
|
|
47
49
|
readonly SLUG: "slug";
|
|
@@ -155,8 +155,6 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
|
|
|
155
155
|
youtubeId?: string | undefined;
|
|
156
156
|
duration?: number | undefined;
|
|
157
157
|
}[] | undefined;
|
|
158
|
-
metaTitle?: string | undefined;
|
|
159
|
-
metaDescription?: string | undefined;
|
|
160
158
|
contentImages?: {
|
|
161
159
|
type: "video" | "image" | "file";
|
|
162
160
|
url: string;
|
|
@@ -167,6 +165,8 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
|
|
|
167
165
|
duration?: number | undefined;
|
|
168
166
|
}[] | undefined;
|
|
169
167
|
authorAvatar?: string | undefined;
|
|
168
|
+
metaTitle?: string | undefined;
|
|
169
|
+
metaDescription?: string | undefined;
|
|
170
170
|
}, {
|
|
171
171
|
status: "draft" | "published" | "archived";
|
|
172
172
|
id: string;
|
|
@@ -208,8 +208,6 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
|
|
|
208
208
|
youtubeId?: string | undefined;
|
|
209
209
|
duration?: number | undefined;
|
|
210
210
|
}[] | undefined;
|
|
211
|
-
metaTitle?: string | undefined;
|
|
212
|
-
metaDescription?: string | undefined;
|
|
213
211
|
contentImages?: {
|
|
214
212
|
type: "video" | "image" | "file";
|
|
215
213
|
url: string;
|
|
@@ -220,6 +218,8 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
|
|
|
220
218
|
duration?: number | undefined;
|
|
221
219
|
}[] | undefined;
|
|
222
220
|
authorAvatar?: string | undefined;
|
|
221
|
+
metaTitle?: string | undefined;
|
|
222
|
+
metaDescription?: string | undefined;
|
|
223
223
|
}>;
|
|
224
224
|
export type BlogPostFromSchema = z.infer<typeof blogPostFirestoreSchema>;
|
|
225
225
|
export declare const blogPostCategorySchema: z.ZodEnum<["news", "tips", "guides", "updates", "community"]>;
|
|
@@ -401,9 +401,9 @@ export declare const blogPostSchema: z.ZodObject<{
|
|
|
401
401
|
authorId?: string | undefined;
|
|
402
402
|
authorName?: string | undefined;
|
|
403
403
|
readTimeMinutes?: number | undefined;
|
|
404
|
+
authorAvatar?: string | undefined;
|
|
404
405
|
metaTitle?: string | undefined;
|
|
405
406
|
metaDescription?: string | undefined;
|
|
406
|
-
authorAvatar?: string | undefined;
|
|
407
407
|
}, {
|
|
408
408
|
status: "draft" | "published" | "archived";
|
|
409
409
|
id: string;
|
|
@@ -439,8 +439,6 @@ export declare const blogPostSchema: z.ZodObject<{
|
|
|
439
439
|
youtubeId?: string | undefined;
|
|
440
440
|
duration?: number | undefined;
|
|
441
441
|
}[] | undefined;
|
|
442
|
-
metaTitle?: string | undefined;
|
|
443
|
-
metaDescription?: string | undefined;
|
|
444
442
|
contentImages?: {
|
|
445
443
|
type: "video" | "image" | "file";
|
|
446
444
|
url: string;
|
|
@@ -451,6 +449,8 @@ export declare const blogPostSchema: z.ZodObject<{
|
|
|
451
449
|
duration?: number | undefined;
|
|
452
450
|
}[] | undefined;
|
|
453
451
|
authorAvatar?: string | undefined;
|
|
452
|
+
metaTitle?: string | undefined;
|
|
453
|
+
metaDescription?: string | undefined;
|
|
454
454
|
}>;
|
|
455
455
|
/** Base Zod schema for list-query parameters. */
|
|
456
456
|
export declare const blogListParamsSchema: z.ZodObject<{
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import type { JsonValue } from "../../../schemas/types";
|
|
1
2
|
import type { FirebaseSieveFields, FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
|
|
2
3
|
import { BaseRepository } from "../../../providers/db-firebase";
|
|
3
4
|
import { type EventCreateInput, type EventDocument, type EventUpdateInput } from "../schemas";
|
|
4
5
|
declare class EventRepository extends BaseRepository<EventDocument> {
|
|
5
6
|
static readonly SIEVE_FIELDS: FirebaseSieveFields;
|
|
6
7
|
constructor();
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Derived on every write path via `applyWriteHooks`. The field, its index and
|
|
10
|
+
* its seed tokens all existed before this — but nothing wrote it for a
|
|
11
|
+
* document created through the app, so only seeded events were searchable.
|
|
12
|
+
*/
|
|
13
|
+
protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
|
|
14
|
+
list(model: SieveModel, opts?: {
|
|
15
|
+
search?: string;
|
|
16
|
+
}): Promise<FirebaseSieveResult<EventDocument>>;
|
|
8
17
|
/**
|
|
9
18
|
* How many events exist per `type` and per `status` — the numbers behind the
|
|
10
19
|
* facet checkboxes, and what lets a type with no events disappear from the
|
|
@@ -6,12 +6,31 @@ import { normalizeError } from "../../../errors/normalize";
|
|
|
6
6
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
7
7
|
import { EVENT_FIELDS, EVENTS_COLLECTION, } from "../schemas";
|
|
8
8
|
import { increment } from "../../../contracts/field-ops";
|
|
9
|
+
import { buildEventSearchTxt } from "../../../utils/search-txt-builders";
|
|
10
|
+
import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
|
|
9
11
|
class EventRepository extends BaseRepository {
|
|
10
12
|
constructor() {
|
|
11
13
|
super(EVENTS_COLLECTION);
|
|
12
14
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Derived on every write path via `applyWriteHooks`. The field, its index and
|
|
17
|
+
* its seed tokens all existed before this — but nothing wrote it for a
|
|
18
|
+
* document created through the app, so only seeded events were searchable.
|
|
19
|
+
*/
|
|
20
|
+
buildSearchTxtFor(data) {
|
|
21
|
+
return buildEventSearchTxt(data);
|
|
22
|
+
}
|
|
23
|
+
async list(model, opts) {
|
|
24
|
+
const plan = planSearchTxt(opts?.search);
|
|
25
|
+
// A search that yielded no usable term must return NOTHING, not everything.
|
|
26
|
+
if (plan.empty)
|
|
27
|
+
return emptySearchResult();
|
|
28
|
+
let baseQuery = this.getCollection();
|
|
29
|
+
if (plan.head) {
|
|
30
|
+
baseQuery = baseQuery.where(EVENT_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
31
|
+
}
|
|
32
|
+
const result = await this.sieveQuery(model, EventRepository.SIEVE_FIELDS, { baseQuery });
|
|
33
|
+
return refineSearchTxt(result, plan.rest);
|
|
15
34
|
}
|
|
16
35
|
/**
|
|
17
36
|
* How many events exist per `type` and per `status` — the numbers behind the
|
|
@@ -105,6 +105,8 @@ export type EventEntryDocument = {
|
|
|
105
105
|
export declare const EVENT_INDEXED_FIELDS: readonly ["searchTxt", "type", "status", "startsAt", "endsAt", "createdAt"];
|
|
106
106
|
export declare const EVENT_ENTRY_INDEXED_FIELDS: readonly ["eventId", "userId", "guestIpHash", "reviewStatus", "status", "submittedAt", "points", "spinWonAt"];
|
|
107
107
|
export declare const EVENT_FIELDS: {
|
|
108
|
+
/** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
|
|
109
|
+
readonly SEARCH_TXT: "searchTxt";
|
|
108
110
|
readonly ID: "id";
|
|
109
111
|
readonly SLUG: "slug";
|
|
110
112
|
readonly TYPE: "type";
|
|
@@ -5,7 +5,10 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
|
|
|
5
5
|
import { cacheManager } from "../../../core";
|
|
6
6
|
import { serverLogger } from "../../../monitoring";
|
|
7
7
|
import { generateUniqueId, slugify, generateBarcodeId } from "../../../utils";
|
|
8
|
-
import {
|
|
8
|
+
import { matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/search-txt";
|
|
9
|
+
// One definition of "which fields feed searchTxt", shared with the seed
|
|
10
|
+
// wrapper. Three copies existed and two were missing the same three fields.
|
|
11
|
+
import { buildProductSearchTxt } from "../../../utils/search-txt-builders";
|
|
9
12
|
import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas";
|
|
10
13
|
import { PRODUCT_FIELDS } from "../../../constants/field-names";
|
|
11
14
|
/**
|
|
@@ -46,22 +49,6 @@ function buildListingKindClause(kind, inverted) {
|
|
|
46
49
|
const op = inverted ? "!=" : "==";
|
|
47
50
|
return `${PRODUCT_FIELDS.LISTING_TYPE}${op}${canonical}`;
|
|
48
51
|
}
|
|
49
|
-
function buildProductSearchTxt(p) {
|
|
50
|
-
return buildSearchTxt([
|
|
51
|
-
p.title,
|
|
52
|
-
p.description,
|
|
53
|
-
p.brand,
|
|
54
|
-
p.brandSlug,
|
|
55
|
-
p.categoryNames,
|
|
56
|
-
p.tags,
|
|
57
|
-
p.features,
|
|
58
|
-
p.condition,
|
|
59
|
-
p.card?.setName,
|
|
60
|
-
p.card?.cardNumber,
|
|
61
|
-
p.grading?.service,
|
|
62
|
-
p.specifications?.map((s) => `${s.name} ${s.value}`),
|
|
63
|
-
]);
|
|
64
|
-
}
|
|
65
52
|
export class ProductRepository extends BaseRepository {
|
|
66
53
|
constructor() {
|
|
67
54
|
super(PRODUCT_COLLECTION);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JsonValue } from "../../../schemas/types";
|
|
1
2
|
import type { FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
|
|
2
3
|
import { BaseRepository, parseSieveDateValue } from "../../../providers/db-firebase";
|
|
3
4
|
import { type ReviewCreateInput, type ReviewDocument } from "../schemas";
|
|
@@ -173,7 +174,16 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
|
173
174
|
};
|
|
174
175
|
listForProduct(productId: string, model: SieveModel): Promise<FirebaseSieveResult<ReviewDocument>>;
|
|
175
176
|
listForStore(storeId: string, model: SieveModel): Promise<FirebaseSieveResult<ReviewDocument>>;
|
|
176
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Derived on every write path via `applyWriteHooks`.
|
|
179
|
+
*
|
|
180
|
+
* `userName` is PII and is deliberately absent from the token source — a
|
|
181
|
+
* searchTxt for a person's name IS that name, re-encoded (D1).
|
|
182
|
+
*/
|
|
183
|
+
protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
|
|
184
|
+
listAll(model: SieveModel, opts?: {
|
|
185
|
+
search?: string;
|
|
186
|
+
}): Promise<FirebaseSieveResult<ReviewDocument>>;
|
|
177
187
|
}
|
|
178
188
|
declare const reviewRepository: ReviewRepository;
|
|
179
189
|
export { ReviewRepository, reviewRepository };
|
|
@@ -3,7 +3,11 @@ import { NotFoundError } from "../../../errors";
|
|
|
3
3
|
import { REVIEW_MAX_RATING, REVIEW_MIN_RATING } from "../../../_internal/shared/features/reviews/config";
|
|
4
4
|
import { decryptPiiFields, encryptPiiFields, piiIndicesFor, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
|
|
5
5
|
import { createReviewId, REVIEW_COLLECTION, } from "../schemas";
|
|
6
|
+
import { buildReviewSearchTxt } from "../../../utils/search-txt-builders";
|
|
7
|
+
import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
|
|
6
8
|
const REVIEW_FIELDS = {
|
|
9
|
+
/** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
|
|
10
|
+
SEARCH_TXT: "searchTxt",
|
|
7
11
|
PRODUCT_ID: "productId",
|
|
8
12
|
USER_ID: "userId",
|
|
9
13
|
STORE_ID: "storeId",
|
|
@@ -309,12 +313,31 @@ class ReviewRepository extends BaseRepository {
|
|
|
309
313
|
});
|
|
310
314
|
return this.decryptSieveResult(result);
|
|
311
315
|
}
|
|
312
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Derived on every write path via `applyWriteHooks`.
|
|
318
|
+
*
|
|
319
|
+
* `userName` is PII and is deliberately absent from the token source — a
|
|
320
|
+
* searchTxt for a person's name IS that name, re-encoded (D1).
|
|
321
|
+
*/
|
|
322
|
+
buildSearchTxtFor(data) {
|
|
323
|
+
return buildReviewSearchTxt(data);
|
|
324
|
+
}
|
|
325
|
+
async listAll(model, opts) {
|
|
326
|
+
const plan = planSearchTxt(opts?.search);
|
|
327
|
+
if (plan.empty)
|
|
328
|
+
return emptySearchResult();
|
|
329
|
+
let baseQuery = this.getCollection();
|
|
330
|
+
if (plan.head) {
|
|
331
|
+
baseQuery = baseQuery.where(REVIEW_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
332
|
+
}
|
|
313
333
|
const result = await this.sieveQuery(model, ReviewRepository.SIEVE_FIELDS, {
|
|
334
|
+
baseQuery,
|
|
314
335
|
defaultPageSize: 50,
|
|
315
336
|
maxPageSize: 200,
|
|
316
337
|
});
|
|
317
|
-
|
|
338
|
+
// Refine BEFORE decrypting: the refine reads `searchTxt`, which is never
|
|
339
|
+
// encrypted, and decrypting rows that are about to be dropped is waste.
|
|
340
|
+
return this.decryptSieveResult(refineSearchTxt(result, plan.rest));
|
|
318
341
|
}
|
|
319
342
|
}
|
|
320
343
|
ReviewRepository.SIEVE_FIELDS = {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { BaseRepository, type SieveModel, type FirebaseSieveResult, type DocumentSnapshot } from "../../../providers/db-firebase";
|
|
8
8
|
import { StoreDocument } from "../schemas";
|
|
9
9
|
import { type HistoryActor } from "../../../_internal/shared/history/index";
|
|
10
|
+
import type { JsonValue } from "../../../schemas/types";
|
|
10
11
|
/** Who/why for a store write that lands in `statusHistory`. */
|
|
11
12
|
export interface StoreWriteContext {
|
|
12
13
|
actor?: HistoryActor;
|
|
@@ -89,7 +90,11 @@ export declare class StoreRepository extends BaseRepository<StoreDocument> {
|
|
|
89
90
|
* Paginated list of stores with Sieve filtering/sorting.
|
|
90
91
|
* Filters to active + public stores for the public listing.
|
|
91
92
|
*/
|
|
92
|
-
|
|
93
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
94
|
+
protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
|
|
95
|
+
listStores(model: SieveModel, activeOnly?: boolean, opts?: {
|
|
96
|
+
search?: string;
|
|
97
|
+
}): Promise<FirebaseSieveResult<StoreDocument>>;
|
|
93
98
|
/**
|
|
94
99
|
* Admin: list all stores regardless of status.
|
|
95
100
|
*/
|
|
@@ -11,6 +11,8 @@ import { DatabaseError } from "../../../errors";
|
|
|
11
11
|
import { normalizeError } from "../../../errors/normalize";
|
|
12
12
|
import { encryptSecret, decryptSecret, } from "../../../security/settings-encryption";
|
|
13
13
|
import { withHistory } from "../../../_internal/shared/history/index";
|
|
14
|
+
import { buildStoreSearchTxt } from "../../../utils/search-txt-builders";
|
|
15
|
+
import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
|
|
14
16
|
export class StoreRepository extends BaseRepository {
|
|
15
17
|
constructor() {
|
|
16
18
|
super(STORE_COLLECTION);
|
|
@@ -178,7 +180,11 @@ export class StoreRepository extends BaseRepository {
|
|
|
178
180
|
* Paginated list of stores with Sieve filtering/sorting.
|
|
179
181
|
* Filters to active + public stores for the public listing.
|
|
180
182
|
*/
|
|
181
|
-
|
|
183
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
184
|
+
buildSearchTxtFor(data) {
|
|
185
|
+
return buildStoreSearchTxt(data);
|
|
186
|
+
}
|
|
187
|
+
async listStores(model, activeOnly = true, opts) {
|
|
182
188
|
const sieveFields = {
|
|
183
189
|
[STORE_FIELDS.STORE_NAME]: { canFilter: true, canSort: true },
|
|
184
190
|
searchTxt: { canFilter: true, canSort: false },
|
|
@@ -196,13 +202,20 @@ export class StoreRepository extends BaseRepository {
|
|
|
196
202
|
"stats.averageRating": { canFilter: false, canSort: true },
|
|
197
203
|
"stats.totalProducts": { canFilter: true, canSort: false },
|
|
198
204
|
};
|
|
205
|
+
const plan = planSearchTxt(opts?.search);
|
|
206
|
+
// A search that yielded no usable term must return NOTHING, not everything.
|
|
207
|
+
if (plan.empty)
|
|
208
|
+
return emptySearchResult();
|
|
199
209
|
let baseQuery = this.getCollection();
|
|
200
210
|
if (activeOnly) {
|
|
201
211
|
baseQuery = baseQuery
|
|
202
212
|
.where(STORE_FIELDS.STATUS, "==", "active")
|
|
203
213
|
.where(STORE_FIELDS.IS_PUBLIC, "==", true);
|
|
204
214
|
}
|
|
205
|
-
|
|
215
|
+
if (plan.head) {
|
|
216
|
+
baseQuery = baseQuery.where(STORE_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
217
|
+
}
|
|
218
|
+
const result = await applySieveToFirestore({
|
|
206
219
|
baseQuery,
|
|
207
220
|
model,
|
|
208
221
|
fields: sieveFields,
|
|
@@ -212,6 +225,7 @@ export class StoreRepository extends BaseRepository {
|
|
|
212
225
|
// WhatsApp bearer token and a list has no use for it.
|
|
213
226
|
mapDoc: (snap) => this.mapDocForList(snap),
|
|
214
227
|
});
|
|
228
|
+
return refineSearchTxt(result, plan.rest);
|
|
215
229
|
}
|
|
216
230
|
/**
|
|
217
231
|
* Admin: list all stores regardless of status.
|
|
@@ -174,6 +174,8 @@ export declare const STORE_HISTORY_PII_FIELDS: readonly ["accessToken", "payoutD
|
|
|
174
174
|
export type { StoreCapability } from "../../auth/permissions/constants";
|
|
175
175
|
export declare const STORE_COLLECTION: "stores";
|
|
176
176
|
export declare const STORE_FIELDS: {
|
|
177
|
+
/** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
|
|
178
|
+
readonly SEARCH_TXT: "searchTxt";
|
|
177
179
|
readonly ID: "id";
|
|
178
180
|
readonly STORE_SLUG: "storeSlug";
|
|
179
181
|
readonly OWNER_ID: "ownerId";
|
|
@@ -45,6 +45,8 @@ export const STORE_HISTORY_PII_FIELDS = [
|
|
|
45
45
|
];
|
|
46
46
|
export const STORE_COLLECTION = "stores";
|
|
47
47
|
export const STORE_FIELDS = {
|
|
48
|
+
/** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
|
|
49
|
+
SEARCH_TXT: "searchTxt",
|
|
48
50
|
ID: "id",
|
|
49
51
|
STORE_SLUG: "storeSlug",
|
|
50
52
|
OWNER_ID: "ownerId",
|
|
@@ -617,9 +617,9 @@ export declare const storeListItemSchema: z.ZodObject<{
|
|
|
617
617
|
storeLogoURL?: string | undefined;
|
|
618
618
|
storeBannerURL?: string | undefined;
|
|
619
619
|
itemsSold?: number | undefined;
|
|
620
|
-
averageRating?: number | undefined;
|
|
621
620
|
totalProducts?: number | undefined;
|
|
622
621
|
totalReviews?: number | undefined;
|
|
622
|
+
averageRating?: number | undefined;
|
|
623
623
|
}, {
|
|
624
624
|
status: string;
|
|
625
625
|
id: string;
|
|
@@ -633,9 +633,9 @@ export declare const storeListItemSchema: z.ZodObject<{
|
|
|
633
633
|
storeLogoURL?: string | undefined;
|
|
634
634
|
storeBannerURL?: string | undefined;
|
|
635
635
|
itemsSold?: number | undefined;
|
|
636
|
-
averageRating?: number | undefined;
|
|
637
636
|
totalProducts?: number | undefined;
|
|
638
637
|
totalReviews?: number | undefined;
|
|
638
|
+
averageRating?: number | undefined;
|
|
639
639
|
}>;
|
|
640
640
|
export declare const storeListParamsSchema: z.ZodObject<{
|
|
641
641
|
category: z.ZodOptional<z.ZodString>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FirestoreDocument } from "@mohasinac/appkit";
|
|
2
2
|
import type { CollectionReference, DocumentData, DocumentReference, DocumentSnapshot, Firestore, Query, Transaction, WriteBatch } from "firebase-admin/firestore";
|
|
3
3
|
import { type FirebaseSieveFields, type FirebaseSieveOptions, type FirebaseSieveResult, type SieveModel } from "./sieve";
|
|
4
|
+
import type { JsonValue } from "../../schemas/types";
|
|
4
5
|
export declare abstract class BaseRepository<T extends DocumentData> {
|
|
5
6
|
protected collection: string;
|
|
6
7
|
constructor(collectionName: string);
|
|
@@ -46,7 +47,7 @@ export declare abstract class BaseRepository<T extends DocumentData> {
|
|
|
46
47
|
* MUST NOT include PII: `searchTxt` stores readable fragments of the source
|
|
47
48
|
* text, so indexing an encrypted field would undo the encryption.
|
|
48
49
|
*/
|
|
49
|
-
protected buildSearchTxtFor(_data: Record<string,
|
|
50
|
+
protected buildSearchTxtFor(_data: Record<string, JsonValue>): string[] | null;
|
|
50
51
|
/**
|
|
51
52
|
* The one place writes are transformed. Every write path routes through here
|
|
52
53
|
* so a new method cannot silently skip encryption.
|
|
@@ -226,8 +226,6 @@ export declare const SCHEMAS: {
|
|
|
226
226
|
youtubeId?: string | undefined;
|
|
227
227
|
duration?: number | undefined;
|
|
228
228
|
}[] | undefined;
|
|
229
|
-
metaTitle?: string | undefined;
|
|
230
|
-
metaDescription?: string | undefined;
|
|
231
229
|
contentImages?: {
|
|
232
230
|
type: "video" | "image" | "file";
|
|
233
231
|
url: string;
|
|
@@ -238,6 +236,8 @@ export declare const SCHEMAS: {
|
|
|
238
236
|
duration?: number | undefined;
|
|
239
237
|
}[] | undefined;
|
|
240
238
|
authorAvatar?: string | undefined;
|
|
239
|
+
metaTitle?: string | undefined;
|
|
240
|
+
metaDescription?: string | undefined;
|
|
241
241
|
}, {
|
|
242
242
|
status: "draft" | "published" | "archived";
|
|
243
243
|
id: string;
|
|
@@ -279,8 +279,6 @@ export declare const SCHEMAS: {
|
|
|
279
279
|
youtubeId?: string | undefined;
|
|
280
280
|
duration?: number | undefined;
|
|
281
281
|
}[] | undefined;
|
|
282
|
-
metaTitle?: string | undefined;
|
|
283
|
-
metaDescription?: string | undefined;
|
|
284
282
|
contentImages?: {
|
|
285
283
|
type: "video" | "image" | "file";
|
|
286
284
|
url: string;
|
|
@@ -291,6 +289,8 @@ export declare const SCHEMAS: {
|
|
|
291
289
|
duration?: number | undefined;
|
|
292
290
|
}[] | undefined;
|
|
293
291
|
authorAvatar?: string | undefined;
|
|
292
|
+
metaTitle?: string | undefined;
|
|
293
|
+
metaDescription?: string | undefined;
|
|
294
294
|
}>;
|
|
295
295
|
readonly carousels: import("zod").ZodObject<{
|
|
296
296
|
createdAt: import("zod").ZodUnion<[import("zod").ZodDate, import("zod").ZodString, import("zod").ZodObject<{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildProductSearchTxt } from "../../utils/search-txt-builders";
|
|
2
2
|
/**
|
|
3
3
|
* Derive `searchTxt` for a seeded product from its own content.
|
|
4
4
|
*
|
|
@@ -16,18 +16,8 @@ import { buildSearchTxt } from "../../utils/search-txt";
|
|
|
16
16
|
* reintroduce the `appkit-seed` idempotency problem of Root Cause #25.
|
|
17
17
|
*/
|
|
18
18
|
export function withProductSearchTxt(p) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
p.description,
|
|
24
|
-
p.brand,
|
|
25
|
-
p.brandSlug,
|
|
26
|
-
p.categoryNames,
|
|
27
|
-
p.tags,
|
|
28
|
-
p.features,
|
|
29
|
-
p.condition,
|
|
30
|
-
p.specifications?.map((s) => `${s.name} ${s.value}`),
|
|
31
|
-
]),
|
|
32
|
-
};
|
|
19
|
+
// Delegates rather than re-deriving. This wrapper listed 9 sources while the
|
|
20
|
+
// write path indexed 12, so a seeded product was findable by fewer terms than
|
|
21
|
+
// the identical product created through the app — with no error on either side.
|
|
22
|
+
return { ...p, searchTxt: buildProductSearchTxt(p) };
|
|
33
23
|
}
|
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
import type { StoreDocument } from "../../features/stores/schemas";
|
|
2
|
-
import type { EventDocument } from "../../features/events/schemas";
|
|
3
|
-
import type { BlogPostDocument } from "../../features/blog/schemas";
|
|
4
|
-
import type { ReviewDocument } from "../../features/reviews/schemas";
|
|
5
|
-
/** stores — name, description, category. No payout details, no contact. */
|
|
1
|
+
import type { StoreDocument } from "../../features/stores/schemas/firestore";
|
|
2
|
+
import type { EventDocument } from "../../features/events/schemas/firestore";
|
|
3
|
+
import type { BlogPostDocument } from "../../features/blog/schemas/firestore";
|
|
4
|
+
import type { ReviewDocument } from "../../features/reviews/schemas/firestore";
|
|
6
5
|
export declare function withStoreSearchTxt<T extends Partial<StoreDocument>>(p: T): T;
|
|
7
|
-
/** events — title, description, type, tags. */
|
|
8
6
|
export declare function withEventSearchTxt<T extends Partial<EventDocument>>(p: T): T;
|
|
9
|
-
/**
|
|
10
|
-
* blogPosts — title, excerpt, category, tags.
|
|
11
|
-
*
|
|
12
|
-
* The full `content` body is deliberately EXCLUDED. FAQ answers are short
|
|
13
|
-
* (measured: max 449 tokens, avg 200, nothing near the 600 cap), but blog bodies
|
|
14
|
-
* are an order of magnitude larger and would blow the cap — at which point
|
|
15
|
-
* `buildSearchTxt` truncates silently and the tail of every post becomes
|
|
16
|
-
* unsearchable with no error anywhere. The excerpt is the searchable summary.
|
|
17
|
-
*/
|
|
18
7
|
export declare function withBlogSearchTxt<T extends Partial<BlogPostDocument>>(p: T): T;
|
|
19
|
-
/** reviews — title, comment, productTitle. `userName` is PII and excluded. */
|
|
20
8
|
export declare function withReviewSearchTxt<T extends Partial<ReviewDocument>>(p: T): T;
|
|
@@ -1,73 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
* Per-collection `searchTxt` derivation for seed data.
|
|
4
|
-
*
|
|
5
|
-
* Applied to the WHOLE array at each file's export, never per record. Writing
|
|
6
|
-
* the field inside object literals is how five product seed files shipped their
|
|
7
|
-
* last fixture — the sold/depleted row — with no tokens at all: the field was
|
|
8
|
-
* added to every record except the one appended at the bottom. The `...p` spread
|
|
9
|
-
* comes FIRST here so a record cannot override or omit it.
|
|
10
|
-
*
|
|
11
|
-
* All of these are pure functions of the record's own content, so they do not
|
|
12
|
-
* reintroduce the `appkit-seed` idempotency problem of Root Cause #25.
|
|
13
|
-
*
|
|
14
|
-
* 🛑 **No PII in any of these.** `searchTxt` stores readable fragments of the
|
|
15
|
-
* source text, so feeding it an encrypted field would undo the encryption.
|
|
16
|
-
* Decision D1: names stay searchable, emails/phones/addresses do not.
|
|
17
|
-
*
|
|
18
|
-
* Each wrapper is typed against its own document rather than a shared
|
|
19
|
-
* `Record<string, unknown>`: that shape trips `audit-unknown-leakage`, and more
|
|
20
|
-
* importantly it would silently accept a field that no longer exists after a
|
|
21
|
-
* rename, which is exactly the class of drift this migration is fixing.
|
|
22
|
-
*/
|
|
23
|
-
const stripHtml = (s) => (s ?? "").replace(/<[^>]+>/g, " ");
|
|
24
|
-
/** stores — name, description, category. No payout details, no contact. */
|
|
1
|
+
import { buildStoreSearchTxt, buildEventSearchTxt, buildBlogSearchTxt, buildReviewSearchTxt, } from "../../utils/search-txt-builders";
|
|
25
2
|
export function withStoreSearchTxt(p) {
|
|
26
|
-
return {
|
|
27
|
-
...p,
|
|
28
|
-
searchTxt: buildSearchTxt([
|
|
29
|
-
p.storeName,
|
|
30
|
-
stripHtml(p.storeDescription),
|
|
31
|
-
p.storeCategory,
|
|
32
|
-
]),
|
|
33
|
-
};
|
|
3
|
+
return { ...p, searchTxt: buildStoreSearchTxt(p) };
|
|
34
4
|
}
|
|
35
|
-
/** events — title, description, type, tags. */
|
|
36
5
|
export function withEventSearchTxt(p) {
|
|
37
|
-
return {
|
|
38
|
-
...p,
|
|
39
|
-
searchTxt: buildSearchTxt([
|
|
40
|
-
p.title,
|
|
41
|
-
stripHtml(p.description),
|
|
42
|
-
p.type,
|
|
43
|
-
p.tags ?? [],
|
|
44
|
-
]),
|
|
45
|
-
};
|
|
6
|
+
return { ...p, searchTxt: buildEventSearchTxt(p) };
|
|
46
7
|
}
|
|
47
|
-
/**
|
|
48
|
-
* blogPosts — title, excerpt, category, tags.
|
|
49
|
-
*
|
|
50
|
-
* The full `content` body is deliberately EXCLUDED. FAQ answers are short
|
|
51
|
-
* (measured: max 449 tokens, avg 200, nothing near the 600 cap), but blog bodies
|
|
52
|
-
* are an order of magnitude larger and would blow the cap — at which point
|
|
53
|
-
* `buildSearchTxt` truncates silently and the tail of every post becomes
|
|
54
|
-
* unsearchable with no error anywhere. The excerpt is the searchable summary.
|
|
55
|
-
*/
|
|
56
8
|
export function withBlogSearchTxt(p) {
|
|
57
|
-
return {
|
|
58
|
-
...p,
|
|
59
|
-
searchTxt: buildSearchTxt([
|
|
60
|
-
p.title,
|
|
61
|
-
stripHtml(p.excerpt),
|
|
62
|
-
p.category,
|
|
63
|
-
p.tags ?? [],
|
|
64
|
-
]),
|
|
65
|
-
};
|
|
9
|
+
return { ...p, searchTxt: buildBlogSearchTxt(p) };
|
|
66
10
|
}
|
|
67
|
-
/** reviews — title, comment, productTitle. `userName` is PII and excluded. */
|
|
68
11
|
export function withReviewSearchTxt(p) {
|
|
69
|
-
return {
|
|
70
|
-
...p,
|
|
71
|
-
searchTxt: buildSearchTxt([p.title, stripHtml(p.comment), p.productTitle]),
|
|
72
|
-
};
|
|
12
|
+
return { ...p, searchTxt: buildReviewSearchTxt(p) };
|
|
73
13
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ProductDocument } from "../features/products/schemas/firestore";
|
|
2
|
+
import type { StoreDocument } from "../features/stores/schemas/firestore";
|
|
3
|
+
import type { EventDocument } from "../features/events/schemas/firestore";
|
|
4
|
+
import type { BlogPostDocument } from "../features/blog/schemas/firestore";
|
|
5
|
+
import type { ReviewDocument } from "../features/reviews/schemas/firestore";
|
|
6
|
+
/** products — title, description, brand, taxonomy, condition, card/grading, specs. */
|
|
7
|
+
export declare function buildProductSearchTxt(p: Partial<ProductDocument>): string[];
|
|
8
|
+
/** stores — name, description, category. No payout details, no contact. */
|
|
9
|
+
export declare function buildStoreSearchTxt(s: Partial<StoreDocument>): string[];
|
|
10
|
+
/** events — title, description, type, tags. */
|
|
11
|
+
export declare function buildEventSearchTxt(e: Partial<EventDocument>): string[];
|
|
12
|
+
/**
|
|
13
|
+
* blogPosts — title, excerpt, category, tags.
|
|
14
|
+
*
|
|
15
|
+
* The full `content` body is deliberately EXCLUDED. FAQ answers are short
|
|
16
|
+
* (measured: max 449 tokens against a 600 cap), but blog bodies are an order of
|
|
17
|
+
* magnitude larger and would blow it — at which point `buildSearchTxt`
|
|
18
|
+
* truncates SILENTLY and the tail of every post becomes unsearchable with no
|
|
19
|
+
* error anywhere. The excerpt is the searchable summary.
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildBlogSearchTxt(b: Partial<BlogPostDocument>): string[];
|
|
22
|
+
/** reviews — title, comment, productTitle. `userName` is PII and excluded. */
|
|
23
|
+
export declare function buildReviewSearchTxt(r: Partial<ReviewDocument>): string[];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: One definition per collection of "which fields feed searchTxt".
|
|
3
|
+
*
|
|
4
|
+
* There were THREE copies of the products derivation — the repository's
|
|
5
|
+
* `buildProductSearchTxt` (12 sources), the seed wrapper
|
|
6
|
+
* `withProductSearchTxt` (9), and `backfill-search-txt.mjs` (9) — and two
|
|
7
|
+
* of them were missing the same three fields. Nothing could catch it:
|
|
8
|
+
* each copy is valid TypeScript producing a valid token array, and a
|
|
9
|
+
* document indexed under fewer tokens is not an error, just unfindable by
|
|
10
|
+
* the terms nobody derived.
|
|
11
|
+
*
|
|
12
|
+
* This module is the write-path definition. The repository calls it, the
|
|
13
|
+
* seed wrapper calls it, and `backfill-search-txt.mjs` MIRRORS it — the
|
|
14
|
+
* script must keep its own copy because it has to run against a checkout
|
|
15
|
+
* whose appkit/dist is stale, so `audit-search-parity` cross-checks the
|
|
16
|
+
* two rather than trusting them to agree.
|
|
17
|
+
*
|
|
18
|
+
* WHAT: Pure functions, document-shape in, token-array out. No Firestore, no
|
|
19
|
+
* repository, no seed import — so every layer can share them without
|
|
20
|
+
* dragging firebase-admin into a bundle that must not have it.
|
|
21
|
+
*
|
|
22
|
+
* 🛑 NEVER feed PII in here. `searchTxt` stores readable fragments of the source
|
|
23
|
+
* text, so indexing an encrypted field would undo the encryption (D1).
|
|
24
|
+
* `reviews.userName` is encrypted and is deliberately absent below.
|
|
25
|
+
*
|
|
26
|
+
* @tag domain:search
|
|
27
|
+
* @tag layer:util
|
|
28
|
+
* @tag access:isomorphic
|
|
29
|
+
*/
|
|
30
|
+
import { buildSearchTxt } from "./search-txt";
|
|
31
|
+
const stripHtml = (s) => (s ?? "").replace(/<[^>]+>/g, " ");
|
|
32
|
+
/** products — title, description, brand, taxonomy, condition, card/grading, specs. */
|
|
33
|
+
export function buildProductSearchTxt(p) {
|
|
34
|
+
return buildSearchTxt([
|
|
35
|
+
p.title,
|
|
36
|
+
p.description,
|
|
37
|
+
p.brand,
|
|
38
|
+
p.brandSlug,
|
|
39
|
+
p.categoryNames,
|
|
40
|
+
p.tags,
|
|
41
|
+
p.features,
|
|
42
|
+
p.condition,
|
|
43
|
+
p.card?.setName,
|
|
44
|
+
p.card?.cardNumber,
|
|
45
|
+
p.grading?.service,
|
|
46
|
+
p.specifications?.map((s) => `${s.name} ${s.value}`),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
/** stores — name, description, category. No payout details, no contact. */
|
|
50
|
+
export function buildStoreSearchTxt(s) {
|
|
51
|
+
return buildSearchTxt([
|
|
52
|
+
s.storeName,
|
|
53
|
+
stripHtml(s.storeDescription),
|
|
54
|
+
s.storeCategory,
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
/** events — title, description, type, tags. */
|
|
58
|
+
export function buildEventSearchTxt(e) {
|
|
59
|
+
return buildSearchTxt([
|
|
60
|
+
e.title,
|
|
61
|
+
stripHtml(e.description),
|
|
62
|
+
e.type,
|
|
63
|
+
e.tags ?? [],
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* blogPosts — title, excerpt, category, tags.
|
|
68
|
+
*
|
|
69
|
+
* The full `content` body is deliberately EXCLUDED. FAQ answers are short
|
|
70
|
+
* (measured: max 449 tokens against a 600 cap), but blog bodies are an order of
|
|
71
|
+
* magnitude larger and would blow it — at which point `buildSearchTxt`
|
|
72
|
+
* truncates SILENTLY and the tail of every post becomes unsearchable with no
|
|
73
|
+
* error anywhere. The excerpt is the searchable summary.
|
|
74
|
+
*/
|
|
75
|
+
export function buildBlogSearchTxt(b) {
|
|
76
|
+
return buildSearchTxt([
|
|
77
|
+
b.title,
|
|
78
|
+
stripHtml(b.excerpt),
|
|
79
|
+
b.category,
|
|
80
|
+
b.tags ?? [],
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
/** reviews — title, comment, productTitle. `userName` is PII and excluded. */
|
|
84
|
+
export function buildReviewSearchTxt(r) {
|
|
85
|
+
return buildSearchTxt([r.title, stripHtml(r.comment), r.productTitle]);
|
|
86
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface SearchTxtPlan {
|
|
2
|
+
/** The single term to push down as `array-contains`, or null for no search. */
|
|
3
|
+
head: string | null;
|
|
4
|
+
/** Terms to AND-refine in memory after the query returns. */
|
|
5
|
+
rest: string[];
|
|
6
|
+
/**
|
|
7
|
+
* True when the caller supplied a search string that yielded no usable term.
|
|
8
|
+
* The caller MUST return an empty page rather than an unfiltered one.
|
|
9
|
+
*/
|
|
10
|
+
empty: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Split a raw search string into the pushdown term and the refine terms.
|
|
14
|
+
* `parseSearchTxtQuery` already orders longest-first — the cheapest proxy for
|
|
15
|
+
* "narrows the most" without cardinality stats.
|
|
16
|
+
*/
|
|
17
|
+
export declare function planSearchTxt(search: string | undefined | null): SearchTxtPlan;
|
|
18
|
+
/** An empty page, shaped like a Sieve result. */
|
|
19
|
+
export declare function emptySearchResult<T>(): {
|
|
20
|
+
items: T[];
|
|
21
|
+
total: number;
|
|
22
|
+
page: number;
|
|
23
|
+
pageSize: number;
|
|
24
|
+
totalPages: number;
|
|
25
|
+
hasMore: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* AND-refine the remaining terms over an already-fetched page.
|
|
29
|
+
*
|
|
30
|
+
* `total` becomes a FLOOR once rows are dropped after the page was cut — it
|
|
31
|
+
* counts this page only. That is the known post-pagination-filter debt, not a
|
|
32
|
+
* new invention: reporting the pre-filter total would promise pages that render
|
|
33
|
+
* empty.
|
|
34
|
+
*/
|
|
35
|
+
export declare function refineSearchTxt<T extends {
|
|
36
|
+
searchTxt?: string[];
|
|
37
|
+
}, R extends {
|
|
38
|
+
items: T[];
|
|
39
|
+
total: number;
|
|
40
|
+
totalPages: number;
|
|
41
|
+
hasMore: boolean;
|
|
42
|
+
}>(result: R, rest: string[]): R;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: The READ half of searchTxt, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Firestore permits ONE array operator per query, so token search is
|
|
5
|
+
* always the same two-step shape: the most selective term becomes an
|
|
6
|
+
* `array-contains` clause, and the remaining terms are AND-refined over
|
|
7
|
+
* the returned page. faqs and products each hand-rolled it, and the
|
|
8
|
+
* products copy carried a comment promising the refine for a long time
|
|
9
|
+
* while nothing refined anything — so "red dranzer" returned everything
|
|
10
|
+
* matching "dranzer".
|
|
11
|
+
*
|
|
12
|
+
* Two rules are easy to get wrong independently, and both fail silently:
|
|
13
|
+
*
|
|
14
|
+
* - `array-contains-any` instead of `array-contains` makes a multi-word
|
|
15
|
+
* search an OR, which reads as "search does nothing".
|
|
16
|
+
* - a query that produced NO usable term must return NOTHING. Falling
|
|
17
|
+
* through to an unfiltered list means a 1-character search returns the
|
|
18
|
+
* whole collection, which also reads as "search does nothing".
|
|
19
|
+
*
|
|
20
|
+
* @tag domain:search
|
|
21
|
+
* @tag layer:util
|
|
22
|
+
* @tag access:isomorphic
|
|
23
|
+
*/
|
|
24
|
+
import { matchesAllSearchTerms, parseSearchTxtQuery } from "./search-txt";
|
|
25
|
+
/**
|
|
26
|
+
* Split a raw search string into the pushdown term and the refine terms.
|
|
27
|
+
* `parseSearchTxtQuery` already orders longest-first — the cheapest proxy for
|
|
28
|
+
* "narrows the most" without cardinality stats.
|
|
29
|
+
*/
|
|
30
|
+
export function planSearchTxt(search) {
|
|
31
|
+
const raw = (search ?? "").trim();
|
|
32
|
+
const terms = parseSearchTxtQuery(raw);
|
|
33
|
+
if (terms.length === 0) {
|
|
34
|
+
return { head: null, rest: [], empty: raw.length > 0 };
|
|
35
|
+
}
|
|
36
|
+
return { head: terms[0], rest: terms.slice(1), empty: false };
|
|
37
|
+
}
|
|
38
|
+
/** An empty page, shaped like a Sieve result. */
|
|
39
|
+
export function emptySearchResult() {
|
|
40
|
+
return { items: [], total: 0, page: 1, pageSize: 0, totalPages: 0, hasMore: false };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* AND-refine the remaining terms over an already-fetched page.
|
|
44
|
+
*
|
|
45
|
+
* `total` becomes a FLOOR once rows are dropped after the page was cut — it
|
|
46
|
+
* counts this page only. That is the known post-pagination-filter debt, not a
|
|
47
|
+
* new invention: reporting the pre-filter total would promise pages that render
|
|
48
|
+
* empty.
|
|
49
|
+
*/
|
|
50
|
+
export function refineSearchTxt(result, rest) {
|
|
51
|
+
if (rest.length === 0)
|
|
52
|
+
return result;
|
|
53
|
+
const items = result.items.filter((row) => matchesAllSearchTerms(row.searchTxt, rest));
|
|
54
|
+
return {
|
|
55
|
+
...result,
|
|
56
|
+
items,
|
|
57
|
+
total: items.length,
|
|
58
|
+
totalPages: items.length === 0 ? 0 : 1,
|
|
59
|
+
hasMore: false,
|
|
60
|
+
};
|
|
61
|
+
}
|