@mohasinac/appkit 4.28.0 → 4.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +5 -0
- package/dist/features/admin/components/AdminContentGuideView.js +1 -1
- package/dist/features/blog/repository/blog.repository.d.ts +4 -0
- package/dist/features/blog/repository/blog.repository.js +1 -0
- package/dist/features/blog/schemas/firestore.d.ts +3 -1
- package/dist/features/blog/schemas/firestore.js +1 -0
- package/dist/features/events/repository/events.repository.js +1 -0
- package/dist/features/events/schemas/firestore.d.ts +3 -1
- package/dist/features/events/schemas/firestore.js +1 -0
- package/dist/features/messages/repository/conversations.repository.js +15 -5
- package/dist/features/products/repository/products.repository.js +15 -7
- package/dist/features/reviews/repository/reviews.repository.d.ts +4 -0
- package/dist/features/reviews/repository/reviews.repository.js +1 -0
- package/dist/features/reviews/schemas/firestore.d.ts +3 -1
- package/dist/features/reviews/schemas/firestore.js +1 -0
- package/dist/features/stores/repository/store.repository.js +1 -0
- package/dist/features/stores/schemas/firestore.d.ts +2 -0
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +0 -1
- package/dist/features/tester/repository/tester-checklist-item.repository.js +6 -15
- package/dist/features/tester/schemas/firestore.d.ts +5 -4
- package/dist/features/tester/schemas/firestore.js +3 -3
- package/dist/security/pii-schemas.d.ts +12 -0
- package/dist/security/pii-schemas.js +15 -0
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +20 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +73 -0
- package/dist/seed/blog-posts-seed-data.d.ts +4 -0
- package/dist/seed/blog-posts-seed-data.js +7 -1
- package/dist/seed/events-seed-data.d.ts +5 -1
- package/dist/seed/events-seed-data.js +7 -1
- package/dist/seed/reviews-seed-data.js +2 -1
- package/dist/seed/stores-seed-data.d.ts +4 -0
- package/dist/seed/stores-seed-data.js +7 -1
- package/package.json +1 -1
|
@@ -27,7 +27,7 @@ import type { StoreDetail, StoreListItem } from "../../../../features/stores/typ
|
|
|
27
27
|
*/
|
|
28
28
|
export declare const PUBLIC_STORE_FIELDS: readonly ["id", "storeSlug", "ownerId", "storeName", "storeDescription", "storeCategory", "storeLogoURL", "storeBannerURL", "status", "isPublic", "stats", "bio", "location", "website", "socialLinks", "returnPolicy", "shippingPolicy", "isVacationMode", "vacationMessage", "createdAt"];
|
|
29
29
|
/** Everything else, with the reason it stays server-side. */
|
|
30
|
-
export declare const PRIVATE_STORE_FIELDS: readonly ["updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt"];
|
|
30
|
+
export declare const PRIVATE_STORE_FIELDS: readonly ["searchTxt", "updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt"];
|
|
31
31
|
/**
|
|
32
32
|
* Accepted input: a `StoreDocument` (the common case) or a loosely-typed
|
|
33
33
|
* Firestore row. The legacy top-level counters below predate `stats` and are
|
|
@@ -26,6 +26,11 @@ export const PUBLIC_STORE_FIELDS = [
|
|
|
26
26
|
];
|
|
27
27
|
/** Everything else, with the reason it stays server-side. */
|
|
28
28
|
export const PRIVATE_STORE_FIELDS = [
|
|
29
|
+
// Internal search index, not content. It is a normalized re-encoding of
|
|
30
|
+
// storeName/description/category — publishing it would leak nothing new, but
|
|
31
|
+
// it has no client reader and would bloat every store payload with a few
|
|
32
|
+
// hundred tokens.
|
|
33
|
+
"searchTxt",
|
|
29
34
|
"updatedAt", // bookkeeping; no public reader
|
|
30
35
|
"whatsappConfig", // Meta access token + WABA/catalog ids — secret
|
|
31
36
|
"adminNotes", // schema comment: never shown to the owner or the public
|
|
@@ -14,7 +14,7 @@ export function AdminContentGuideView() {
|
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
Icon: BookOpen, title: "FAQs",
|
|
17
|
-
content: (_jsxs(Ul, { className: GC.listMuted, children: [_jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "Categories" }), " and where they appear: Shipping (help + footer), Returns (help), Payments (help), Auctions (help), Pre-orders (help). Set ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "showOnHomepage: true" }), " for up to 5 FAQs shown on the public home page."] }), _jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "
|
|
17
|
+
content: (_jsxs(Ul, { className: GC.listMuted, children: [_jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "Categories" }), " and where they appear: Shipping (help + footer), Returns (help), Payments (help), Auctions (help), Pre-orders (help). Set ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "showOnHomepage: true" }), " for up to 5 FAQs shown on the public home page."] }), _jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "Search" }), ": handled automatically \u2014 search terms are derived from the question, answer, category and tags when you save. There is nothing to fill in."] }), _jsxs(Li, { children: [_jsx(Span, { weight: "bold", children: "priority + order" }), ": ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "priority" }), " sorts FAQs within their category; ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "order" }), " is the global absolute position. ", _jsx(Code, { size: "xs", padding: "xs", rounded: "default", surface: "subtle", children: "isPinned: true" }), " floats the FAQ above non-pinned items in its category."] })] })),
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
Icon: Layout, title: "Carousel Slides",
|
|
@@ -18,6 +18,10 @@ declare class BlogRepository extends BaseRepository<BlogPostDocument> {
|
|
|
18
18
|
/** Other published posts by the same author. */
|
|
19
19
|
findByAuthor(authorId: string, excludeId: string, limit?: number): Promise<BlogPostDocument[]>;
|
|
20
20
|
static readonly SIEVE_FIELDS: {
|
|
21
|
+
searchTxt: {
|
|
22
|
+
canFilter: boolean;
|
|
23
|
+
canSort: boolean;
|
|
24
|
+
};
|
|
21
25
|
id: {
|
|
22
26
|
canFilter: boolean;
|
|
23
27
|
canSort: boolean;
|
|
@@ -161,6 +161,7 @@ class BlogRepository extends BaseRepository {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
BlogRepository.SIEVE_FIELDS = {
|
|
164
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
164
165
|
id: { canFilter: true, canSort: false },
|
|
165
166
|
title: { canFilter: true, canSort: true },
|
|
166
167
|
slug: { canFilter: true, canSort: false },
|
|
@@ -11,6 +11,8 @@ export declare const BlogPostStatusValues: {
|
|
|
11
11
|
readonly ARCHIVED: BlogPostStatus;
|
|
12
12
|
};
|
|
13
13
|
export interface BlogPostDocument extends BaseDocument {
|
|
14
|
+
/** Normalized edge-n-gram search field — see appkit/src/utils/search-txt.ts. */
|
|
15
|
+
searchTxt?: string[];
|
|
14
16
|
title: string;
|
|
15
17
|
slug: string;
|
|
16
18
|
excerpt: string;
|
|
@@ -38,7 +40,7 @@ export interface BlogPostDocument extends BaseDocument {
|
|
|
38
40
|
metaDescription?: string;
|
|
39
41
|
}
|
|
40
42
|
export declare const BLOG_POSTS_COLLECTION: "blogPosts";
|
|
41
|
-
export declare const BLOG_POSTS_INDEXED_FIELDS: readonly ["status", "publishedAt", "isFeatured", "category", "slug"];
|
|
43
|
+
export declare const BLOG_POSTS_INDEXED_FIELDS: readonly ["searchTxt", "status", "publishedAt", "isFeatured", "category", "slug"];
|
|
42
44
|
export declare const BLOG_POST_FIELDS: {
|
|
43
45
|
readonly ID: "id";
|
|
44
46
|
readonly TITLE: "title";
|
|
@@ -166,6 +166,7 @@ class EventRepository extends BaseRepository {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
EventRepository.SIEVE_FIELDS = {
|
|
169
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
169
170
|
type: { canFilter: true, canSort: false },
|
|
170
171
|
status: { canFilter: true, canSort: true },
|
|
171
172
|
title: { canFilter: true, canSort: true },
|
|
@@ -9,6 +9,8 @@ import type { LotteryConfig } from "../../lottery/types";
|
|
|
9
9
|
export declare const EVENTS_COLLECTION: "events";
|
|
10
10
|
export declare const EVENT_ENTRIES_COLLECTION: "eventEntries";
|
|
11
11
|
export interface EventDocument extends BaseDocument {
|
|
12
|
+
/** Normalized edge-n-gram search field — see appkit/src/utils/search-txt.ts. */
|
|
13
|
+
searchTxt?: string[];
|
|
12
14
|
slug?: string;
|
|
13
15
|
type: EventType;
|
|
14
16
|
title: string;
|
|
@@ -100,7 +102,7 @@ export type EventEntryDocument = {
|
|
|
100
102
|
guestIpHash?: string;
|
|
101
103
|
submittedAt: Date;
|
|
102
104
|
};
|
|
103
|
-
export declare const EVENT_INDEXED_FIELDS: readonly ["type", "status", "startsAt", "endsAt", "createdAt"];
|
|
105
|
+
export declare const EVENT_INDEXED_FIELDS: readonly ["searchTxt", "type", "status", "startsAt", "endsAt", "createdAt"];
|
|
104
106
|
export declare const EVENT_ENTRY_INDEXED_FIELDS: readonly ["eventId", "userId", "guestIpHash", "reviewStatus", "status", "submittedAt", "points", "spinWonAt"];
|
|
105
107
|
export declare const EVENT_FIELDS: {
|
|
106
108
|
readonly ID: "id";
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* or unread counters.
|
|
10
10
|
*/
|
|
11
11
|
import { getAdminDb } from "../../../providers/db-firebase";
|
|
12
|
+
import { encryptPiiFields, decryptPiiFields } from "../../../security/pii-encrypt";
|
|
13
|
+
import { CONVERSATION_PII_FIELDS } from "../../../security/pii-schemas";
|
|
12
14
|
import { serverLogger } from "../../../monitoring";
|
|
13
15
|
import { CONVERSATION_FIELDS } from "../../../constants/field-names";
|
|
14
16
|
import { CONVERSATIONS_COLLECTION, } from "../schemas/firestore";
|
|
@@ -37,7 +39,11 @@ function normaliseMessage(raw) {
|
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
function normaliseDoc(id, raw) {
|
|
40
|
-
|
|
42
|
+
// Decrypt here rather than at the six call sites: this is the one function
|
|
43
|
+
// every read path already goes through, so a new reader cannot forget it.
|
|
44
|
+
// `markRead` deliberately does NOT decrypt — it reads raw messages, flips
|
|
45
|
+
// isRead and writes them straight back, so ciphertext round-trips untouched.
|
|
46
|
+
const r = decryptPiiFields(raw, [...CONVERSATION_PII_FIELDS]);
|
|
41
47
|
return {
|
|
42
48
|
id,
|
|
43
49
|
buyerId: String(r.buyerId ?? ""),
|
|
@@ -102,8 +108,9 @@ export class ConversationsRepository {
|
|
|
102
108
|
createdAt: now,
|
|
103
109
|
updatedAt: now,
|
|
104
110
|
};
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
const encryptedDoc = encryptPiiFields(newDoc, [...CONVERSATION_PII_FIELDS]);
|
|
112
|
+
tx.set(ref, encryptedDoc);
|
|
113
|
+
return normaliseDoc(stableId, encryptedDoc);
|
|
107
114
|
});
|
|
108
115
|
}
|
|
109
116
|
async findById(id) {
|
|
@@ -168,8 +175,11 @@ export class ConversationsRepository {
|
|
|
168
175
|
unreadSeller,
|
|
169
176
|
updatedAt: now,
|
|
170
177
|
};
|
|
171
|
-
|
|
172
|
-
|
|
178
|
+
// Encrypt before the write; normaliseDoc decrypts on the way back out,
|
|
179
|
+
// so the caller still receives plaintext.
|
|
180
|
+
const encryptedPatch = encryptPiiFields(patch, [...CONVERSATION_PII_FIELDS]);
|
|
181
|
+
tx.update(ref, encryptedPatch);
|
|
182
|
+
return normaliseDoc(conversationId, { ...data, ...encryptedPatch });
|
|
173
183
|
});
|
|
174
184
|
}
|
|
175
185
|
catch (error) {
|
|
@@ -373,16 +373,24 @@ export class ProductRepository extends BaseRepository {
|
|
|
373
373
|
if (opts?.storeId) {
|
|
374
374
|
baseQuery = baseQuery.where(PRODUCT_FIELDS.STORE_ID, "==", opts.storeId);
|
|
375
375
|
}
|
|
376
|
+
// Longest term first — only ONE may become the array-contains clause.
|
|
376
377
|
const searchTxt = parseSearchTxtQuery(opts?.search ?? "");
|
|
377
|
-
if (searchTxt.length > 0
|
|
378
|
-
// Firestore
|
|
379
|
-
|
|
380
|
-
|
|
378
|
+
if (searchTxt.length > 0) {
|
|
379
|
+
// Firestore permits a single array operator per query, so when a search
|
|
380
|
+
// and a category filter are both present the SEARCH takes the clause and
|
|
381
|
+
// the category is refined by the caller.
|
|
382
|
+
//
|
|
383
|
+
// Two shapes here have each been reverted once and are both guarded by
|
|
384
|
+
// scripts/audit-search-semantics.mjs:
|
|
385
|
+
//
|
|
386
|
+
// 1. This branch was an empty `if` body — it emitted NO clause, so a
|
|
387
|
+
// search combined with `categoriesIn` dropped BOTH filters and
|
|
388
|
+
// returned an unrelated superset, silently.
|
|
389
|
+
// 2. Multi-term used `array-contains-any`, which is OR — "red dranzer"
|
|
390
|
+
// matched anything with either word. One `array-contains` on the most
|
|
391
|
+
// selective term, remaining terms AND-refined against `searchTxt`.
|
|
381
392
|
baseQuery = baseQuery.where(PRODUCT_FIELDS.SEARCH_TXT, "array-contains", searchTxt[0]);
|
|
382
393
|
}
|
|
383
|
-
else if (searchTxt.length > 1) {
|
|
384
|
-
baseQuery = baseQuery.where(PRODUCT_FIELDS.SEARCH_TXT, "array-contains-any", searchTxt);
|
|
385
|
-
}
|
|
386
394
|
if (searchTxt.length === 0 && opts?.categoriesIn && opts.categoriesIn.length > 0) {
|
|
387
395
|
let categoriesIn = opts.categoriesIn;
|
|
388
396
|
if (categoriesIn.length > ProductRepository.MAX_ARRAY_CONTAINS_ANY) {
|
|
@@ -88,6 +88,10 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
|
88
88
|
/** Find reviews written by a user filtered by their role as reviewer. */
|
|
89
89
|
findByUserAsRole(userId: string, role: "buyer" | "seller"): Promise<ReviewDocument[]>;
|
|
90
90
|
static readonly SIEVE_FIELDS: {
|
|
91
|
+
searchTxt: {
|
|
92
|
+
canFilter: boolean;
|
|
93
|
+
canSort: boolean;
|
|
94
|
+
};
|
|
91
95
|
id: {
|
|
92
96
|
canFilter: boolean;
|
|
93
97
|
canSort: boolean;
|
|
@@ -318,6 +318,7 @@ class ReviewRepository extends BaseRepository {
|
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
320
|
ReviewRepository.SIEVE_FIELDS = {
|
|
321
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
321
322
|
id: { canFilter: true, canSort: false },
|
|
322
323
|
productId: { canFilter: true, canSort: false },
|
|
323
324
|
productTitle: { canFilter: true, canSort: true },
|
|
@@ -18,6 +18,8 @@ export interface ReviewVideoField {
|
|
|
18
18
|
trimEnd?: number;
|
|
19
19
|
}
|
|
20
20
|
export interface ReviewDocument extends BaseDocument {
|
|
21
|
+
/** Normalized edge-n-gram search field — see appkit/src/utils/search-txt.ts. */
|
|
22
|
+
searchTxt?: string[];
|
|
21
23
|
productId: string;
|
|
22
24
|
productTitle: string;
|
|
23
25
|
/** Denormalized from the product at review-creation time — drives the correct detail-page link (e.g. prize-draw items link to /prize-draws/[slug], not /products/[slug]). */
|
|
@@ -61,7 +63,7 @@ export interface ReviewDocument extends BaseDocument {
|
|
|
61
63
|
reviewerRole?: "buyer" | "seller";
|
|
62
64
|
}
|
|
63
65
|
export declare const REVIEW_COLLECTION: "reviews";
|
|
64
|
-
export declare const REVIEW_INDEXED_FIELDS: readonly ["productId", "storeId", "userId", "revieweeId", "reviewerRole", "status", "rating", "verified", "featured", "createdAt"];
|
|
66
|
+
export declare const REVIEW_INDEXED_FIELDS: readonly ["searchTxt", "productId", "storeId", "userId", "revieweeId", "reviewerRole", "status", "rating", "verified", "featured", "createdAt"];
|
|
65
67
|
export declare const DEFAULT_REVIEW_DATA: Partial<ReviewDocument>;
|
|
66
68
|
export declare const REVIEW_PUBLIC_FIELDS: readonly ["id", "productId", "userName", "userAvatar", "rating", "title", "comment", "helpfulCount", "verified", "images", "createdAt"];
|
|
67
69
|
export declare const REVIEW_UPDATABLE_FIELDS: readonly ["rating", "title", "comment", "images"];
|
|
@@ -154,6 +154,7 @@ export class StoreRepository extends BaseRepository {
|
|
|
154
154
|
async listStores(model, activeOnly = true) {
|
|
155
155
|
const sieveFields = {
|
|
156
156
|
[STORE_FIELDS.STORE_NAME]: { canFilter: true, canSort: true },
|
|
157
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
157
158
|
[STORE_FIELDS.STORE_CATEGORY]: { canFilter: true, canSort: false },
|
|
158
159
|
[STORE_FIELDS.STATUS]: { canFilter: true, canSort: false },
|
|
159
160
|
[STORE_FIELDS.IS_PUBLIC]: { canFilter: true, canSort: false },
|
|
@@ -49,6 +49,8 @@ export declare const StoreStatusValues: {
|
|
|
49
49
|
};
|
|
50
50
|
export type StoreStatus = (typeof StoreStatusValues)[keyof typeof StoreStatusValues];
|
|
51
51
|
export interface StoreDocument extends BaseDocument {
|
|
52
|
+
/** Normalized edge-n-gram search field — see appkit/src/utils/search-txt.ts. */
|
|
53
|
+
searchTxt?: string[];
|
|
52
54
|
storeSlug: string;
|
|
53
55
|
ownerId: string;
|
|
54
56
|
/** Tester sandbox flag — set on the shared admin-seeded test store; swept by testerSandboxCleanup. */
|
|
@@ -4,7 +4,6 @@ import { type TesterChecklistItemDocument, type TesterChecklistItemCreateInput,
|
|
|
4
4
|
export declare class TesterChecklistItemRepository extends BaseRepository<TesterChecklistItemDocument> {
|
|
5
5
|
static readonly SIEVE_FIELDS: FirebaseSieveFields;
|
|
6
6
|
constructor();
|
|
7
|
-
private buildSearchTokens;
|
|
8
7
|
createItem(input: TesterChecklistItemCreateInput): Promise<TesterChecklistItemDocument>;
|
|
9
8
|
update(id: string, data: TesterChecklistItemUpdateInput): Promise<TesterChecklistItemDocument>;
|
|
10
9
|
listActive(): Promise<TesterChecklistItemDocument[]>;
|
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
import { BaseRepository, parseSieveDateValue } from "../../../providers/db-firebase";
|
|
2
|
+
import { buildSearchTxt } from "../../../utils/search-txt";
|
|
2
3
|
import { DatabaseError } from "../../../errors";
|
|
3
4
|
import { TESTER_CHECKLIST_ITEM_COLLECTION, TESTER_CHECKLIST_ITEM_FIELDS, createChecklistItemId, } from "../schemas/firestore";
|
|
4
5
|
export class TesterChecklistItemRepository extends BaseRepository {
|
|
5
6
|
constructor() {
|
|
6
7
|
super(TESTER_CHECKLIST_ITEM_COLLECTION);
|
|
7
8
|
}
|
|
8
|
-
buildSearchTokens(input) {
|
|
9
|
-
const rawText = [input.label, input.description, input.groupLabel, input.pageLabel]
|
|
10
|
-
.filter(Boolean)
|
|
11
|
-
.join(" ")
|
|
12
|
-
.toLowerCase();
|
|
13
|
-
return Array.from(new Set(rawText
|
|
14
|
-
.split(/[^a-z0-9]+/i)
|
|
15
|
-
.map((token) => token.trim())
|
|
16
|
-
.filter((token) => token.length >= 2))).slice(0, 50);
|
|
17
|
-
}
|
|
18
9
|
async createItem(input) {
|
|
19
10
|
const id = createChecklistItemId(input.groupKey, input.pageKey, input.label);
|
|
20
|
-
const
|
|
21
|
-
return this.createWithId(id, { ...input,
|
|
11
|
+
const searchTxt = buildSearchTxt([input.label, input.description, input.groupLabel, input.pageLabel]);
|
|
12
|
+
return this.createWithId(id, { ...input, searchTxt });
|
|
22
13
|
}
|
|
23
14
|
async update(id, data) {
|
|
24
15
|
const current = await this.findById(id);
|
|
@@ -28,7 +19,7 @@ export class TesterChecklistItemRepository extends BaseRepository {
|
|
|
28
19
|
const merged = { ...current, ...data };
|
|
29
20
|
return super.update(id, {
|
|
30
21
|
...data,
|
|
31
|
-
|
|
22
|
+
searchTxt: buildSearchTxt([merged.label, merged.description, merged.groupLabel, merged.pageLabel]),
|
|
32
23
|
});
|
|
33
24
|
}
|
|
34
25
|
async listActive() {
|
|
@@ -90,7 +81,7 @@ export class TesterChecklistItemRepository extends BaseRepository {
|
|
|
90
81
|
isActive: true,
|
|
91
82
|
version: nextVersion,
|
|
92
83
|
previousVersionId: old.id,
|
|
93
|
-
|
|
84
|
+
searchTxt: buildSearchTxt([old.label, old.description, old.groupLabel, old.pageLabel]),
|
|
94
85
|
});
|
|
95
86
|
await this.update(old.id, { supersededByItemId: newId });
|
|
96
87
|
return newItem;
|
|
@@ -129,7 +120,7 @@ TesterChecklistItemRepository.SIEVE_FIELDS = {
|
|
|
129
120
|
order: { canFilter: true, canSort: true },
|
|
130
121
|
phase: { canFilter: true, canSort: true },
|
|
131
122
|
isActive: { canFilter: true, canSort: false },
|
|
132
|
-
|
|
123
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
133
124
|
createdAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
134
125
|
bugConfirmed: { canFilter: true, canSort: false },
|
|
135
126
|
};
|
|
@@ -17,7 +17,8 @@ export interface TesterChecklistItemDocument extends BaseDocument {
|
|
|
17
17
|
order: number;
|
|
18
18
|
phase: number;
|
|
19
19
|
isActive: boolean;
|
|
20
|
-
searchTokens
|
|
20
|
+
/** Normalized edge-n-gram search field. Replaced `searchTokens`. */
|
|
21
|
+
searchTxt: string[];
|
|
21
22
|
adminOnly?: boolean;
|
|
22
23
|
bugConfirmed?: boolean;
|
|
23
24
|
bugHunterId?: string;
|
|
@@ -29,9 +30,9 @@ export interface TesterChecklistItemDocument extends BaseDocument {
|
|
|
29
30
|
}
|
|
30
31
|
export declare const TESTER_CHECKLIST_ITEM_COLLECTION: "testerChecklistItems";
|
|
31
32
|
export declare const TESTER_CHECKLIST_ITEM_ID_PREFIX: "checklist-";
|
|
32
|
-
export declare const TESTER_CHECKLIST_ITEM_INDEXED_FIELDS: readonly ["groupKey", "pageKey", "phase", "order", "isActive", "
|
|
33
|
+
export declare const TESTER_CHECKLIST_ITEM_INDEXED_FIELDS: readonly ["groupKey", "pageKey", "phase", "order", "isActive", "searchTxt", "createdAt"];
|
|
33
34
|
export declare const DEFAULT_TESTER_CHECKLIST_ITEM_DATA: Partial<TesterChecklistItemDocument>;
|
|
34
|
-
export type TesterChecklistItemCreateInput = Omit<TesterChecklistItemDocument, "id" | "createdAt" | "updatedAt" | "
|
|
35
|
+
export type TesterChecklistItemCreateInput = Omit<TesterChecklistItemDocument, "id" | "createdAt" | "updatedAt" | "searchTxt">;
|
|
35
36
|
export type TesterChecklistItemUpdateInput = Partial<Pick<TesterChecklistItemDocument, "groupKey" | "groupLabel" | "pageKey" | "pageLabel" | "label" | "description" | "href" | "order" | "phase" | "isActive" | "adminOnly" | "bugConfirmed" | "bugHunterId" | "bugHunterName" | "bugConfirmedAt" | "version" | "previousVersionId" | "supersededByItemId">>;
|
|
36
37
|
export declare const testerChecklistItemQueryHelpers: {
|
|
37
38
|
readonly byGroup: (groupKey: string) => readonly ["groupKey", "==", string];
|
|
@@ -84,7 +85,7 @@ export declare const TESTER_CHECKLIST_ITEM_FIELDS: {
|
|
|
84
85
|
readonly ORDER: "order";
|
|
85
86
|
readonly PHASE: "phase";
|
|
86
87
|
readonly IS_ACTIVE: "isActive";
|
|
87
|
-
readonly SEARCH_TOKENS: "
|
|
88
|
+
readonly SEARCH_TOKENS: "searchTxt";
|
|
88
89
|
readonly ADMIN_ONLY: "adminOnly";
|
|
89
90
|
readonly BUG_CONFIRMED: "bugConfirmed";
|
|
90
91
|
readonly BUG_HUNTER_ID: "bugHunterId";
|
|
@@ -14,14 +14,14 @@ export const TESTER_CHECKLIST_ITEM_INDEXED_FIELDS = [
|
|
|
14
14
|
"phase",
|
|
15
15
|
"order",
|
|
16
16
|
"isActive",
|
|
17
|
-
"
|
|
17
|
+
"searchTxt",
|
|
18
18
|
"createdAt",
|
|
19
19
|
];
|
|
20
20
|
export const DEFAULT_TESTER_CHECKLIST_ITEM_DATA = {
|
|
21
21
|
order: 0,
|
|
22
22
|
phase: 1,
|
|
23
23
|
isActive: true,
|
|
24
|
-
|
|
24
|
+
searchTxt: [],
|
|
25
25
|
adminOnly: false,
|
|
26
26
|
};
|
|
27
27
|
export const testerChecklistItemQueryHelpers = {
|
|
@@ -80,7 +80,7 @@ export const TESTER_CHECKLIST_ITEM_FIELDS = {
|
|
|
80
80
|
ORDER: "order",
|
|
81
81
|
PHASE: "phase",
|
|
82
82
|
IS_ACTIVE: "isActive",
|
|
83
|
-
SEARCH_TOKENS: "
|
|
83
|
+
SEARCH_TOKENS: "searchTxt",
|
|
84
84
|
ADMIN_ONLY: "adminOnly",
|
|
85
85
|
BUG_CONFIRMED: "bugConfirmed",
|
|
86
86
|
BUG_HUNTER_ID: "bugHunterId",
|
|
@@ -42,6 +42,18 @@ export declare const NEWSLETTER_PII_INDEX_MAP: Record<string, string>;
|
|
|
42
42
|
export declare const TOKEN_PII_FIELDS: readonly ["email"];
|
|
43
43
|
/** Blind-index mapping for token email queries */
|
|
44
44
|
export declare const TOKEN_PII_INDEX_MAP: Record<string, string>;
|
|
45
|
+
/**
|
|
46
|
+
* PII fields in conversations — buyer↔seller message bodies.
|
|
47
|
+
*
|
|
48
|
+
* `messages[].body` uses the array-path form, and `lastMessage` is the
|
|
49
|
+
* denormalised copy of the newest body: encrypting one without the other would
|
|
50
|
+
* leave every conversation's latest message readable at rest, which is most of
|
|
51
|
+
* the exposure.
|
|
52
|
+
*
|
|
53
|
+
* Participant display names are deliberately NOT here — decision D1 keeps names
|
|
54
|
+
* searchable, and both sides already know who they are talking to.
|
|
55
|
+
*/
|
|
56
|
+
export declare const CONVERSATION_PII_FIELDS: readonly ["lastMessage", "messages[].body"];
|
|
45
57
|
/** PII fields in reviews */
|
|
46
58
|
export declare const REVIEW_PII_FIELDS: readonly ["userName"];
|
|
47
59
|
/** Blind-index mapping for queryable review PII */
|
|
@@ -58,6 +58,21 @@ export const TOKEN_PII_FIELDS = ["email"];
|
|
|
58
58
|
export const TOKEN_PII_INDEX_MAP = {
|
|
59
59
|
email: "emailIndex",
|
|
60
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* PII fields in conversations — buyer↔seller message bodies.
|
|
63
|
+
*
|
|
64
|
+
* `messages[].body` uses the array-path form, and `lastMessage` is the
|
|
65
|
+
* denormalised copy of the newest body: encrypting one without the other would
|
|
66
|
+
* leave every conversation's latest message readable at rest, which is most of
|
|
67
|
+
* the exposure.
|
|
68
|
+
*
|
|
69
|
+
* Participant display names are deliberately NOT here — decision D1 keeps names
|
|
70
|
+
* searchable, and both sides already know who they are talking to.
|
|
71
|
+
*/
|
|
72
|
+
export const CONVERSATION_PII_FIELDS = [
|
|
73
|
+
"lastMessage",
|
|
74
|
+
"messages[].body",
|
|
75
|
+
];
|
|
61
76
|
/** PII fields in reviews */
|
|
62
77
|
export const REVIEW_PII_FIELDS = ["userName"];
|
|
63
78
|
/** Blind-index mapping for queryable review PII */
|
|
@@ -0,0 +1,20 @@
|
|
|
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. */
|
|
6
|
+
export declare function withStoreSearchTxt<T extends Partial<StoreDocument>>(p: T): T;
|
|
7
|
+
/** events — title, description, type, tags. */
|
|
8
|
+
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
|
+
export declare function withBlogSearchTxt<T extends Partial<BlogPostDocument>>(p: T): T;
|
|
19
|
+
/** reviews — title, comment, productTitle. `userName` is PII and excluded. */
|
|
20
|
+
export declare function withReviewSearchTxt<T extends Partial<ReviewDocument>>(p: T): T;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { buildSearchTxt } from "../../utils/search-txt";
|
|
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. */
|
|
25
|
+
export function withStoreSearchTxt(p) {
|
|
26
|
+
return {
|
|
27
|
+
...p,
|
|
28
|
+
searchTxt: buildSearchTxt([
|
|
29
|
+
p.storeName,
|
|
30
|
+
stripHtml(p.storeDescription),
|
|
31
|
+
p.storeCategory,
|
|
32
|
+
]),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** events — title, description, type, tags. */
|
|
36
|
+
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
|
+
};
|
|
46
|
+
}
|
|
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
|
+
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
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** reviews — title, comment, productTitle. `userName` is PII and excluded. */
|
|
68
|
+
export function withReviewSearchTxt(p) {
|
|
69
|
+
return {
|
|
70
|
+
...p,
|
|
71
|
+
searchTxt: buildSearchTxt([p.title, stripHtml(p.comment), p.productTitle]),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { BlogPostDocument } from "../features/blog/schemas";
|
|
2
|
+
/**
|
|
3
|
+
* Tokens derived here, not per record — see _helpers/search-txt-wrappers.ts for
|
|
4
|
+
* why the wrapper form is what makes omission impossible.
|
|
5
|
+
*/
|
|
2
6
|
export declare const blogPostsSeedData: Partial<BlogPostDocument>[];
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* @tag consumers:seed/index.ts,seed/runner.ts
|
|
14
14
|
* @tag sideEffects:none
|
|
15
15
|
*/
|
|
16
|
+
import { withBlogSearchTxt } from "./_helpers/search-txt-wrappers";
|
|
16
17
|
import { seedExtMedia } from "./_helpers/media";
|
|
17
18
|
const NOW = new Date();
|
|
18
19
|
const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
|
|
19
|
-
|
|
20
|
+
const _rawblogPostsSeedData = [
|
|
20
21
|
// ── GUIDES (6) ────────────────────────────────────────────────────────────
|
|
21
22
|
{
|
|
22
23
|
id: "blog-spot-genuine-takara-tomy-beyblade",
|
|
@@ -434,3 +435,8 @@ export const blogPostsSeedData = [
|
|
|
434
435
|
updatedAt: daysAgo(60),
|
|
435
436
|
},
|
|
436
437
|
];
|
|
438
|
+
/**
|
|
439
|
+
* Tokens derived here, not per record — see _helpers/search-txt-wrappers.ts for
|
|
440
|
+
* why the wrapper form is what makes omission impossible.
|
|
441
|
+
*/
|
|
442
|
+
export const blogPostsSeedData = _rawblogPostsSeedData.map(withBlogSearchTxt);
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { EventDocument, EventEntryDocument } from "../features/events/schemas";
|
|
2
|
-
export declare const eventsSeedData: EventDocument[];
|
|
3
2
|
export declare const eventEntriesSeedData: EventEntryDocument[];
|
|
3
|
+
/**
|
|
4
|
+
* Tokens derived here, not per record — see _helpers/search-txt-wrappers.ts for
|
|
5
|
+
* why the wrapper form is what makes omission impossible.
|
|
6
|
+
*/
|
|
7
|
+
export declare const eventsSeedData: EventDocument[];
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
* @tag consumers:seed/index.ts,seed/runner.ts
|
|
14
14
|
* @tag sideEffects:none
|
|
15
15
|
*/
|
|
16
|
+
import { withEventSearchTxt } from "./_helpers/search-txt-wrappers";
|
|
16
17
|
import { EVENT_FIELDS, EVENT_ENTRY_FIELDS } from "../features/events/schemas";
|
|
17
18
|
import { seedExtMedia } from "./_helpers/media";
|
|
18
19
|
const NOW = new Date();
|
|
19
20
|
const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
|
|
20
21
|
const daysAhead = (n) => new Date(NOW.getTime() + n * 86400000);
|
|
21
|
-
|
|
22
|
+
const _raweventsSeedData = [
|
|
22
23
|
// ── 1. ACTIVE — Poll: Favourite Blader Original Series ────────────────────
|
|
23
24
|
{
|
|
24
25
|
id: "event-favourite-blader-poll",
|
|
@@ -469,3 +470,8 @@ export const eventEntriesSeedData = [
|
|
|
469
470
|
createdAt: daysAgo(2),
|
|
470
471
|
},
|
|
471
472
|
];
|
|
473
|
+
/**
|
|
474
|
+
* Tokens derived here, not per record — see _helpers/search-txt-wrappers.ts for
|
|
475
|
+
* why the wrapper form is what makes omission impossible.
|
|
476
|
+
*/
|
|
477
|
+
export const eventsSeedData = _raweventsSeedData.map(withEventSearchTxt);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* @tag consumers:seed/index.ts,seed/runner.ts
|
|
15
15
|
* @tag sideEffects:none
|
|
16
16
|
*/
|
|
17
|
+
import { withReviewSearchTxt } from "./_helpers/search-txt-wrappers";
|
|
17
18
|
import { seedExtMedia } from "./_helpers/media";
|
|
18
19
|
const NOW = new Date();
|
|
19
20
|
const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
|
|
@@ -176,4 +177,4 @@ for (let i = 0; i < 14; i++) {
|
|
|
176
177
|
updatedAt: daysAgo(daysAgoCreated),
|
|
177
178
|
});
|
|
178
179
|
}
|
|
179
|
-
export const reviewsSeedData = _rawReviewsSeedData;
|
|
180
|
+
export const reviewsSeedData = _rawReviewsSeedData.map(withReviewSearchTxt);
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { StoreDocument } from "../features/stores/schemas";
|
|
2
|
+
/**
|
|
3
|
+
* Tokens derived here, not per record — see _helpers/search-txt-wrappers.ts for
|
|
4
|
+
* why the wrapper form is what makes omission impossible.
|
|
5
|
+
*/
|
|
2
6
|
export declare const storesSeedData: Partial<StoreDocument>[];
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* @tag consumers:seed/runner.ts
|
|
16
16
|
* @tag sideEffects:none
|
|
17
17
|
*/
|
|
18
|
+
import { withStoreSearchTxt } from "./_helpers/search-txt-wrappers";
|
|
18
19
|
import { STORE_FIELDS } from "../constants/field-names";
|
|
19
20
|
import { seedExtMedia } from "./_helpers/media";
|
|
20
21
|
const NOW = new Date();
|
|
@@ -30,7 +31,7 @@ const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
|
|
|
30
31
|
function entry(at, actorRole, trigger, changes, extra) {
|
|
31
32
|
return { at, actorRole, trigger, changes, ...extra };
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
const _rawstoresSeedData = [
|
|
34
35
|
// ── Store 1: LetItRip Official (admin-owned, platform storefront) ──────────
|
|
35
36
|
{
|
|
36
37
|
id: "store-letitrip-official",
|
|
@@ -262,3 +263,8 @@ export const storesSeedData = [
|
|
|
262
263
|
],
|
|
263
264
|
},
|
|
264
265
|
];
|
|
266
|
+
/**
|
|
267
|
+
* Tokens derived here, not per record — see _helpers/search-txt-wrappers.ts for
|
|
268
|
+
* why the wrapper form is what makes omission impossible.
|
|
269
|
+
*/
|
|
270
|
+
export const storesSeedData = _rawstoresSeedData.map(withStoreSearchTxt);
|