@mohasinac/appkit 4.28.0 → 4.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  2. package/dist/_internal/server/features/stores/adapters.js +5 -0
  3. package/dist/_internal/server/jobs/handlers/_helpers.d.ts +22 -0
  4. package/dist/_internal/server/jobs/handlers/_helpers.js +25 -1
  5. package/dist/features/admin/components/AdminContentGuideView.js +1 -1
  6. package/dist/features/blog/repository/blog.repository.d.ts +4 -0
  7. package/dist/features/blog/repository/blog.repository.js +1 -0
  8. package/dist/features/blog/schemas/firestore.d.ts +3 -1
  9. package/dist/features/blog/schemas/firestore.js +1 -0
  10. package/dist/features/events/repository/events.repository.js +1 -0
  11. package/dist/features/events/schemas/firestore.d.ts +3 -1
  12. package/dist/features/events/schemas/firestore.js +1 -0
  13. package/dist/features/messages/repository/conversations.repository.js +15 -5
  14. package/dist/features/products/repository/products.repository.js +15 -7
  15. package/dist/features/reviews/repository/reviews.repository.d.ts +4 -0
  16. package/dist/features/reviews/repository/reviews.repository.js +1 -0
  17. package/dist/features/reviews/schemas/firestore.d.ts +3 -1
  18. package/dist/features/reviews/schemas/firestore.js +1 -0
  19. package/dist/features/stores/repository/store.repository.js +1 -0
  20. package/dist/features/stores/schemas/firestore.d.ts +2 -0
  21. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +0 -1
  22. package/dist/features/tester/repository/tester-checklist-item.repository.js +6 -15
  23. package/dist/features/tester/schemas/firestore.d.ts +5 -4
  24. package/dist/features/tester/schemas/firestore.js +3 -3
  25. package/dist/security/pii-schemas.d.ts +12 -0
  26. package/dist/security/pii-schemas.js +15 -0
  27. package/dist/seed/_helpers/search-txt-wrappers.d.ts +20 -0
  28. package/dist/seed/_helpers/search-txt-wrappers.js +73 -0
  29. package/dist/seed/blog-posts-seed-data.d.ts +4 -0
  30. package/dist/seed/blog-posts-seed-data.js +7 -1
  31. package/dist/seed/events-seed-data.d.ts +5 -1
  32. package/dist/seed/events-seed-data.js +7 -1
  33. package/dist/seed/reviews-seed-data.js +2 -1
  34. package/dist/seed/stores-seed-data.d.ts +4 -0
  35. package/dist/seed/stores-seed-data.js +25 -1
  36. 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
@@ -10,5 +10,27 @@ export { getAdminRealtimeDb };
10
10
  * Query docs tagged isTestData:true in a collection. `cutoff: null` returns every
11
11
  * tester-sandbox doc regardless of expiry (manual force-purge); a real Date returns
12
12
  * only the ones past testDataExpiresAt (the scheduled 7-day sweep).
13
+ *
14
+ * ## 🛑 `isTestData` without `testDataExpiresAt` means PERMANENT, never purge
15
+ *
16
+ * The flag has two uses, and only one of them is a sandbox fixture:
17
+ *
18
+ * · a fixture — `isTestData: true` PLUS `testDataExpiresAt`. Disposable,
19
+ * swept on the 7-day cycle. Every doc in `features/tester/seed-data/` is
20
+ * this shape; the invariant was checked across all six files.
21
+ * · permanently hidden — `isTestData: true` and NO expiry. Real, permanent
22
+ * content that simply must not reach the public or the sitemap.
23
+ * `store-tester-qa-seller` is the case that forced this distinction.
24
+ *
25
+ * The scheduled path already separates them: `testDataExpiresAt <= cutoff`
26
+ * excludes docs lacking the field. The FORCE path did not — `cutoff: null`
27
+ * drops that clause and returned everything, so `tester:purge-sandbox --force`
28
+ * would have deleted a permanent seed store. Hence the filter below.
29
+ *
30
+ * Filtered in memory rather than as a query clause: a Firestore inequality on
31
+ * `testDataExpiresAt` would exclude every doc missing it, which is the trap
32
+ * documented in CLAUDE.md's anti-pattern table — correct here by luck, wrong
33
+ * the moment someone reuses the shape. In-memory keeps the intent legible, and
34
+ * the read is already bounded at 200.
13
35
  */
14
36
  export declare function getTestDataRefs(db: FirebaseFirestore.Firestore, collection: string, cutoff: Date | null): Promise<FirebaseFirestore.DocumentReference[]>;
@@ -22,6 +22,28 @@ export { getAdminRealtimeDb };
22
22
  * Query docs tagged isTestData:true in a collection. `cutoff: null` returns every
23
23
  * tester-sandbox doc regardless of expiry (manual force-purge); a real Date returns
24
24
  * only the ones past testDataExpiresAt (the scheduled 7-day sweep).
25
+ *
26
+ * ## 🛑 `isTestData` without `testDataExpiresAt` means PERMANENT, never purge
27
+ *
28
+ * The flag has two uses, and only one of them is a sandbox fixture:
29
+ *
30
+ * · a fixture — `isTestData: true` PLUS `testDataExpiresAt`. Disposable,
31
+ * swept on the 7-day cycle. Every doc in `features/tester/seed-data/` is
32
+ * this shape; the invariant was checked across all six files.
33
+ * · permanently hidden — `isTestData: true` and NO expiry. Real, permanent
34
+ * content that simply must not reach the public or the sitemap.
35
+ * `store-tester-qa-seller` is the case that forced this distinction.
36
+ *
37
+ * The scheduled path already separates them: `testDataExpiresAt <= cutoff`
38
+ * excludes docs lacking the field. The FORCE path did not — `cutoff: null`
39
+ * drops that clause and returned everything, so `tester:purge-sandbox --force`
40
+ * would have deleted a permanent seed store. Hence the filter below.
41
+ *
42
+ * Filtered in memory rather than as a query clause: a Firestore inequality on
43
+ * `testDataExpiresAt` would exclude every doc missing it, which is the trap
44
+ * documented in CLAUDE.md's anti-pattern table — correct here by luck, wrong
45
+ * the moment someone reuses the shape. In-memory keeps the intent legible, and
46
+ * the read is already bounded at 200.
25
47
  */
26
48
  export async function getTestDataRefs(db, collection, cutoff) {
27
49
  let q = db
@@ -30,5 +52,7 @@ export async function getTestDataRefs(db, collection, cutoff) {
30
52
  if (cutoff)
31
53
  q = q.where("testDataExpiresAt", "<=", cutoff);
32
54
  const snap = await q.limit(200).get();
33
- return snap.docs.map((d) => d.ref);
55
+ return snap.docs
56
+ .filter((d) => cutoff !== null || d.get("testDataExpiresAt") != null)
57
+ .map((d) => d.ref);
34
58
  }
@@ -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: "searchTokens[]" }), ": Must be manually filled \u2014 drives the FAQ search feature. Include synonyms, misspellings, and related keywords."] }), _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."] })] })),
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";
@@ -10,6 +10,7 @@ export const BlogPostStatusValues = {
10
10
  };
11
11
  export const BLOG_POSTS_COLLECTION = "blogPosts";
12
12
  export const BLOG_POSTS_INDEXED_FIELDS = [
13
+ "searchTxt",
13
14
  "status",
14
15
  "publishedAt",
15
16
  "isFeatured",
@@ -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";
@@ -4,6 +4,7 @@
4
4
  export const EVENTS_COLLECTION = "events";
5
5
  export const EVENT_ENTRIES_COLLECTION = "eventEntries";
6
6
  export const EVENT_INDEXED_FIELDS = [
7
+ "searchTxt",
7
8
  "type",
8
9
  "status",
9
10
  "startsAt",
@@ -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
- const r = raw;
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
- tx.set(ref, newDoc);
106
- return normaliseDoc(stableId, newDoc);
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
- tx.update(ref, patch);
172
- return normaliseDoc(conversationId, { ...data, ...patch });
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 && opts?.categoriesIn && opts.categoriesIn.length > 0) {
378
- // Firestore cannot combine two array operators in one query
379
- }
380
- else if (searchTxt.length === 1) {
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"];
@@ -10,6 +10,7 @@ export const ReviewStatusValues = {
10
10
  };
11
11
  export const REVIEW_COLLECTION = "reviews";
12
12
  export const REVIEW_INDEXED_FIELDS = [
13
+ "searchTxt",
13
14
  "productId",
14
15
  "storeId",
15
16
  "userId",
@@ -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 searchTokens = this.buildSearchTokens(input);
21
- return this.createWithId(id, { ...input, searchTokens });
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
- searchTokens: this.buildSearchTokens(merged),
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
- searchTokens: this.buildSearchTokens(old),
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
- searchTokens: { canFilter: true, canSort: false },
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: string[];
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", "searchTokens", "createdAt"];
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" | "searchTokens">;
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: "searchTokens";
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
- "searchTokens",
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
- searchTokens: [],
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: "searchTokens",
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
- export const blogPostsSeedData = [
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
- export const eventsSeedData = [
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
- export const storesSeedData = [
34
+ const _rawstoresSeedData = [
34
35
  // ── Store 1: LetItRip Official (admin-owned, platform storefront) ──────────
35
36
  {
36
37
  id: "store-letitrip-official",
@@ -177,6 +178,24 @@ export const storesSeedData = [
177
178
  },
178
179
  isPublic: true,
179
180
  isVacationMode: false,
181
+ /*
182
+ * Reachable, but never advertised to search engines.
183
+ *
184
+ * This is a QA store — real enough for a tester to exercise seller flows
185
+ * against, and not something that belongs in Google's index. `isTestData`
186
+ * is what the codebase already uses to express that: the sitemap's store
187
+ * fetcher filters it out (it has no viewer), public store listings hide
188
+ * it, and `canViewTestData()` keeps it visible to testers and admins — so
189
+ * the checklist case linking to `/stores/store-tester-qa-seller` still
190
+ * resolves.
191
+ *
192
+ * 🛑 NOTE the deliberate absence of `testDataExpiresAt`. Every sandbox
193
+ * FIXTURE carries one; this store is permanent. `getTestDataRefs` skips
194
+ * docs without an expiry on the force-purge path precisely so
195
+ * `tester:purge-sandbox --force` cannot delete it. Do not add an expiry
196
+ * here — that would put a permanent seed store in range of the sweep.
197
+ */
198
+ isTestData: true,
180
199
  stats: { totalProducts: 0, itemsSold: 0, totalReviews: 0, averageRating: 0 },
181
200
  capabilities: ["host_preorders", "verified_seller", "create_coupons"],
182
201
  createdAt: daysAgo(1),
@@ -262,3 +281,8 @@ export const storesSeedData = [
262
281
  ],
263
282
  },
264
283
  ];
284
+ /**
285
+ * Tokens derived here, not per record — see _helpers/search-txt-wrappers.ts for
286
+ * why the wrapper form is what makes omission impossible.
287
+ */
288
+ export const storesSeedData = _rawstoresSeedData.map(withStoreSearchTxt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.28.0",
3
+ "version": "4.29.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"