@mohasinac/appkit 4.30.13 → 4.30.16

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 (54) hide show
  1. package/dist/_internal/server/functions/https.js +17 -2
  2. package/dist/_internal/server/jobs/core/listingProcessor.js +5 -1
  3. package/dist/features/addresses/repository/addresses.repository.d.ts +12 -1
  4. package/dist/features/addresses/repository/addresses.repository.js +16 -7
  5. package/dist/features/admin/components/AdminBlogView.js +8 -1
  6. package/dist/features/admin/components/AdminCouponsView.js +8 -1
  7. package/dist/features/admin/components/AdminOffersView.js +9 -1
  8. package/dist/features/admin/components/AdminPayoutsView.js +9 -1
  9. package/dist/features/admin/components/AdminReviewsView.js +14 -1
  10. package/dist/features/admin/components/AdminScammersView.js +15 -1
  11. package/dist/features/admin/components/DataListingView.d.ts +22 -1
  12. package/dist/features/admin/components/DataListingView.js +7 -0
  13. package/dist/features/admin/hooks/useAdminListing.d.ts +9 -0
  14. package/dist/features/admin/hooks/useAdminListing.js +30 -2
  15. package/dist/features/auth/repository/user.repository.d.ts +22 -1
  16. package/dist/features/auth/repository/user.repository.js +31 -28
  17. package/dist/features/cart/repository/cart.repository.d.ts +15 -2
  18. package/dist/features/cart/repository/cart.repository.js +15 -13
  19. package/dist/features/events/components/AdminEventsView.js +7 -1
  20. package/dist/features/orders/repository/orders.repository.d.ts +12 -1
  21. package/dist/features/orders/repository/orders.repository.js +14 -5
  22. package/dist/features/payments/repository/payout.repository.d.ts +19 -9
  23. package/dist/features/payments/repository/payout.repository.js +26 -20
  24. package/dist/features/products/components/ProductFilters.d.ts +19 -1
  25. package/dist/features/products/components/ProductFilters.js +11 -3
  26. package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
  27. package/dist/features/products/components/ProductsIndexListing.js +40 -27
  28. package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
  29. package/dist/features/reviews/repository/reviews.repository.js +16 -19
  30. package/dist/features/scams/repository/scammer.repository.d.ts +10 -1
  31. package/dist/features/scams/repository/scammer.repository.js +20 -2
  32. package/dist/features/scams/schemas/firestore.d.ts +10 -0
  33. package/dist/features/scams/schemas/firestore.js +1 -0
  34. package/dist/features/scams/schemas/index.d.ts +10 -10
  35. package/dist/features/seller/components/SellerCouponsView.js +7 -1
  36. package/dist/features/seller/components/SellerOffersView.js +8 -1
  37. package/dist/features/seller/components/SellerOrdersView.js +8 -1
  38. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +161 -17
  39. package/dist/providers/db-firebase/base.repository.d.ts +12 -0
  40. package/dist/providers/db-firebase/base.repository.js +14 -2
  41. package/dist/react/hooks/usePendingFilters.d.ts +12 -2
  42. package/dist/react/hooks/usePendingFilters.js +2 -2
  43. package/dist/react/hooks/usePendingTable.d.ts +9 -2
  44. package/dist/schemas/registry.d.ts +8 -8
  45. package/dist/security/pii-schemas.d.ts +19 -1
  46. package/dist/security/pii-schemas.js +19 -1
  47. package/dist/seed/_helpers/search-txt-wrappers.d.ts +2 -0
  48. package/dist/seed/_helpers/search-txt-wrappers.js +4 -1
  49. package/dist/seed/scammers-seed-data.js +10 -1
  50. package/dist/styles.css +1 -1
  51. package/dist/tailwind-utilities.css +1 -1
  52. package/dist/utils/search-txt-builders.d.ts +13 -0
  53. package/dist/utils/search-txt-builders.js +20 -0
  54. package/package.json +2 -9
@@ -64,8 +64,23 @@ export const listingProcessor = defineFunction({
64
64
  handler: listingProcessorHandler,
65
65
  options: {
66
66
  region: REGION,
67
- timeoutSeconds: 30,
68
- memory: "256MiB",
67
+ // Matched to the `gateway` function's budget ON PURPOSE, and this is not
68
+ // over-provisioning.
69
+ //
70
+ // `listingProcessorHandler` is reachable two ways: this direct function,
71
+ // and `gateway` with `action: "listingProcessor"` (it is in
72
+ // GATEWAY_HANDLERS). `src/lib/listing-processor.ts` chooses between them on
73
+ // whether FIREBASE_FUNCTION_GATEWAY_URL is set — so an ENVIRONMENT VARIABLE
74
+ // decided which limits the same query ran under. At 30s/256MiB here against
75
+ // the gateway's 120s/512MiB, a heavy listing query could succeed through
76
+ // one path and time out through the other, with nothing at the call site
77
+ // saying which path served it. That is the shape of a production bug nobody
78
+ // can reproduce.
79
+ //
80
+ // Cloud Functions bill on use, not on the declared ceiling, so aligning the
81
+ // two costs nothing while idle and removes the non-determinism.
82
+ timeoutSeconds: 120,
83
+ memory: "512MiB",
69
84
  maxInstances: 20,
70
85
  minInstances: 0,
71
86
  cors: false,
@@ -11,7 +11,11 @@ import { normalizeError } from "../../../../errors/normalize";
11
11
  const DEFAULT_SORT = "-createdAt";
12
12
  const DEFAULT_PAGE = 1;
13
13
  const DEFAULT_PAGE_SIZE = 20;
14
- const MAX_PAGE_SIZE = 100;
14
+ // 50, matching the app's own ceiling. This was 100, so a caller reaching the
15
+ // Function could ask for twice what every route permits — and the routes are
16
+ // where Rule #6's `pageSize <= 50` is enforced. A limit that only one of two
17
+ // entry points applies is not a limit.
18
+ const MAX_PAGE_SIZE = 50;
15
19
  class ListingError extends Error {
16
20
  constructor(status, message) {
17
21
  super(message);
@@ -3,7 +3,18 @@ import { type AddressBanStatus, type AddressCreateInput, type AddressDocument, t
3
3
  export declare class AddressesRepository extends BaseRepository<AddressDocument> {
4
4
  constructor();
5
5
  private decryptAddress;
6
- private encryptAddressData;
6
+ /**
7
+ * Declared, not hand-rolled.
8
+ *
9
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
10
+ * `encryptAddressData` did — encrypt the listed fields, then add the mapped blind
11
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
12
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
13
+ * `*InBatch` family that inherited unhooked base implementations while the
14
+ * encryption lived in a private method only three call sites remembered to
15
+ * call.
16
+ */
17
+ protected piiFields: readonly ["fullName", "phone", "addressLine1", "addressLine2"];
7
18
  protected mapDoc<D = AddressDocument>(snap: DocumentSnapshot): D;
8
19
  createWithId(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
9
20
  update(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
@@ -13,28 +13,37 @@ import { normalizeError } from "../../../errors/normalize";
13
13
  import { DatabaseError } from "../../../errors";
14
14
  import { serverLogger } from "../../../monitoring";
15
15
  import { BaseRepository, getFirestoreCount, prepareForFirestore, } from "../../../providers/db-firebase";
16
- import { ADDRESS_PII_FIELDS, decryptPiiFields, encryptPiiFields, } from "../../../security";
16
+ import { ADDRESS_PII_FIELDS, decryptPiiFields, } from "../../../security";
17
17
  import { ADDRESS_FIELDS, ADDRESSES_COLLECTION, } from "../schemas";
18
18
  export class AddressesRepository extends BaseRepository {
19
19
  constructor() {
20
20
  super(ADDRESSES_COLLECTION);
21
+ /**
22
+ * Declared, not hand-rolled.
23
+ *
24
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
25
+ * `encryptAddressData` did — encrypt the listed fields, then add the mapped blind
26
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
27
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
28
+ * `*InBatch` family that inherited unhooked base implementations while the
29
+ * encryption lived in a private method only three call sites remembered to
30
+ * call.
31
+ */
32
+ this.piiFields = ADDRESS_PII_FIELDS;
21
33
  }
22
34
  decryptAddress(doc) {
23
35
  return decryptPiiFields(doc, [...ADDRESS_PII_FIELDS]);
24
36
  }
25
- encryptAddressData(data) {
26
- return encryptPiiFields(data, [...ADDRESS_PII_FIELDS]);
27
- }
28
37
  mapDoc(snap) {
29
38
  const raw = super.mapDoc(snap);
30
39
  return this.decryptAddress(raw);
31
40
  }
32
41
  async createWithId(id, data) {
33
- const encrypted = this.encryptAddressData(data);
42
+ const encrypted = this.applyWriteHooks(data);
34
43
  return super.createWithId(id, encrypted);
35
44
  }
36
45
  async update(id, data) {
37
- const encrypted = this.encryptAddressData(data);
46
+ const encrypted = this.applyWriteHooks(data);
38
47
  return super.update(id, encrypted);
39
48
  }
40
49
  async listByOwner(ownerType, ownerId) {
@@ -91,7 +100,7 @@ export class AddressesRepository extends BaseRepository {
91
100
  createdAt: now,
92
101
  updatedAt: now,
93
102
  };
94
- const encrypted = this.encryptAddressData(addressData);
103
+ const encrypted = this.applyWriteHooks(addressData);
95
104
  await docRef.set(prepareForFirestore(encrypted));
96
105
  serverLogger.info("Address created", {
97
106
  ownerType,
@@ -23,7 +23,14 @@ export function AdminBlogView({ children, ...props }) {
23
23
  title: "Blog Posts",
24
24
  // buildBlogSearchTxt indexes title + excerpt + category + tags — NOT the post
25
25
  // body, which is not tokenised.
26
- searchPlaceholder: "Search by title, excerpt, category or tag…",
26
+ // buildBlogSearchTxt indexes title + excerpt + category + tags — NOT the
27
+ // post body, which is not tokenised.
28
+ search: {
29
+ placeholder: "Search by title, excerpt, category or tag…",
30
+ mode: "partial",
31
+ fields: ["title", "excerpt", "category", "tags"],
32
+ commit: "debounce",
33
+ },
27
34
  emptyLabel: "No blog posts found",
28
35
  filterKeys: ["status", "isFeatured"],
29
36
  defaultSort: sortBy("publishedAt", "DESC"),
@@ -53,7 +53,14 @@ export function AdminCouponsView({ children, onBulkArchive, onBulkDelete, ...pro
53
53
  portal: "admin",
54
54
  title: "Coupons",
55
55
  // buildCouponSearchTxt indexes code + name + description — nothing else.
56
- searchPlaceholder: "Search by code, name or description…",
56
+ // buildCouponSearchTxt indexes exactly these three. Partial prefix
57
+ // matching, so debounce is the right commit here.
58
+ search: {
59
+ placeholder: "Search by code, name or description…",
60
+ mode: "partial",
61
+ fields: ["code", "name", "description"],
62
+ commit: "debounce",
63
+ },
57
64
  emptyLabel: "No coupons found",
58
65
  filterKeys: ["type", "showExpired"],
59
66
  defaultSort: sortBy("createdAt", "DESC"),
@@ -106,7 +106,15 @@ export function AdminOffersView({ children, ...props }) {
106
106
  // buildOfferSearchTxt indexes productTitle + productSlug + storeName. It does
107
107
  // NOT index buyerName or buyerEmail — those are PII, and the placeholder
108
108
  // must not promise a match it will never make.
109
- searchPlaceholder: "Search by product or store…",
109
+ // buildOfferSearchTxt indexes productTitle + productSlug + storeName.
110
+ // NOT buyerName or buyerEmail — those are PII (D1), and a placeholder
111
+ // naming them would promise a match this corpus cannot make.
112
+ search: {
113
+ placeholder: "Search by product or store…",
114
+ mode: "partial",
115
+ fields: ["productTitle", "productSlug", "storeName"],
116
+ commit: "debounce",
117
+ },
110
118
  emptyLabel: "No offers found",
111
119
  filterKeys: ["status"],
112
120
  defaultSort: sortBy("createdAt", "DESC"),
@@ -81,7 +81,15 @@ export function AdminPayoutsView({ children, ...props }) {
81
81
  const config = {
82
82
  portal: "admin",
83
83
  title: "Payouts",
84
- searchPlaceholder: "Search stores, payout IDs, or order groups",
84
+ // 🛑 This said "Search stores, payout IDs, or order groups". The route
85
+ // resolves `q` as `sellerEmailIndex == piiBlindIndex(q)` when it contains
86
+ // an "@", else `sellerName == q` — both EXACT, and neither is a store, a
87
+ // payout id or an order group. All three promised things were unmatchable.
88
+ search: {
89
+ placeholder: "Seller email or name (exact match)",
90
+ mode: "exact",
91
+ fields: ["sellerEmail", "sellerName"],
92
+ },
85
93
  emptyLabel: "No payouts found",
86
94
  filterKeys: ["status", "showAllPayouts"],
87
95
  defaultSort: sortBy("createdAt", "DESC"),
@@ -69,7 +69,20 @@ export function AdminReviewsView({ children, ...props }) {
69
69
  const config = {
70
70
  portal: "admin",
71
71
  title: "Reviews",
72
- searchPlaceholder: "Search reviews, products, or seller names",
72
+ // 🛑 This said "Search reviews, products, or seller names". The route
73
+ // resolves `q` as `userNameIndex == piiBlindIndex(q)` — an EXACT match on
74
+ // the REVIEWER's name, and nothing else. It matched no review text, no
75
+ // product and no seller, so a partial name returned an empty list that was
76
+ // indistinguishable from "no such reviewer".
77
+ //
78
+ // `mode: "exact"` is not a limitation to apologise for: the field is
79
+ // encrypted and ciphertext has no usable prefix (D1). Say so in the
80
+ // placeholder instead of promising what the corpus cannot do.
81
+ search: {
82
+ placeholder: "Reviewer name (exact match)",
83
+ mode: "exact",
84
+ fields: ["userName"],
85
+ },
73
86
  emptyLabel: "No reviews found",
74
87
  filterKeys: ["status", "rating"],
75
88
  defaultSort: sortBy("createdAt", "DESC"),
@@ -42,7 +42,21 @@ export function AdminScammersView({ children, ...props }) {
42
42
  const config = {
43
43
  portal: "admin",
44
44
  title: "Scammers",
45
- searchPlaceholder: "Search by name, phone, UPI ID",
45
+ // buildScammerSearchTxt indexes all four identity arrays plus scamType.
46
+ // They are stored PLAINTEXT on purpose — a victim googles a UPI id before
47
+ // paying, so SEO is the feature — which is why this collection can have a
48
+ // partial-match box where users/reviews/payouts cannot.
49
+ //
50
+ // The old box promised the same three fields but ran `displayNames@=q`,
51
+ // i.e. array-contains on a WHOLE element: "Vikram" matched nothing
52
+ // against ["Vikram M", "Vikram Mehta"], and phone/UPI were never
53
+ // searched at all.
54
+ search: {
55
+ placeholder: "Search by name, phone, UPI ID or email…",
56
+ mode: "partial",
57
+ fields: ["displayNames", "phones", "upiIds", "emails", "scamType"],
58
+ commit: "debounce",
59
+ },
46
60
  emptyLabel: "No scammer profiles found",
47
61
  filterKeys: ["status"],
48
62
  defaultSort: sortBy("createdAt", "DESC"),
@@ -105,8 +105,29 @@ export interface ListingViewConfig<TResponse, TRow extends {
105
105
  placeholder: string;
106
106
  /** `exact` for a field resolved through an HMAC blind index. */
107
107
  mode?: "partial" | "exact";
108
- /** Fields the endpoint actually matches on. */
108
+ /**
109
+ * Fields the endpoint actually matches on.
110
+ *
111
+ * Not decoration: this is what lets the audit check that the placeholder
112
+ * does not promise a match the corpus cannot make. A box reading "search
113
+ * by buyer" over an index built from productTitle/storeName/trackingNumber
114
+ * is the same lie as a box the endpoint ignores entirely — just harder to
115
+ * notice, because it returns a plausible empty list instead of nothing.
116
+ */
109
117
  fields?: readonly string[];
118
+ /**
119
+ * When the term reaches the URL. Defaults to `"enter"`, which is the
120
+ * behaviour every existing box already had.
121
+ *
122
+ * 🛑 `"debounce"` is invalid with `mode: "exact"` and the audit blocks the
123
+ * pairing. An exact search resolves an HMAC blind index or a whole-value
124
+ * equality, so every keystroke before the last is a query GUARANTEED to
125
+ * match nothing — the user watches "no results" while typing a value that
126
+ * is in fact present, and each of those round trips is a billed read.
127
+ */
128
+ commit?: "enter" | "debounce";
129
+ /** Debounce delay in ms. Ignored unless `commit: "debounce"`. */
130
+ debounceMs?: number;
110
131
  };
111
132
  emptyLabel?: string;
112
133
  sortOptions: ListingSortOption[];
@@ -26,6 +26,13 @@ export function DataListingView({ config, }) {
26
26
  const listing = useAdminListing({
27
27
  ...config,
28
28
  initialView: effectiveInitialView,
29
+ // Mapped explicitly: `...config` cannot reach these, they live one level
30
+ // down under `search`. An exact-match search is never debounced — every
31
+ // keystroke before the last is a query guaranteed to match nothing — so
32
+ // the mode forces "enter" regardless of what the view asked for, and the
33
+ // audit blocks the pairing at author time rather than relying on this.
34
+ searchCommit: config.search?.mode === "exact" ? "enter" : config.search?.commit,
35
+ searchDebounceMs: config.search?.debounceMs,
29
36
  });
30
37
  const { view, setView, table, panel, searchInput, setSearchInput, commitSearch, filterOpen, setFilterOpen, openFilters, applyFilters, clearFilters, pendingFilters, setPendingFilters, activeFilterCount, hasActiveState, resetAll, rows, isLoading, errorMessage, currentPage, totalPages, selection, pageSize, setPageSize, } = listing;
31
38
  const selectionContext = {
@@ -20,6 +20,15 @@ export interface AdminListingConfig<TResponse, TRow extends {
20
20
  * a non-"All" filter state (e.g. hide inactive rows) on first load, while
21
21
  * "Clear filters" (which writes an explicit "") still shows everything. */
22
22
  filterDefaults?: Record<string, string>;
23
+ /**
24
+ * When a typed term reaches the URL. `"enter"` (the default) is what every
25
+ * box already did — commit on Enter or the button, never on keystroke.
26
+ *
27
+ * Passed down from `ListingViewConfig.search.commit`; see the 🛑 there for
28
+ * why `"debounce"` is invalid with `mode: "exact"`.
29
+ */
30
+ searchCommit?: "enter" | "debounce";
31
+ searchDebounceMs?: number;
23
32
  }
24
33
  export declare function useAdminListing<TResponse, TRow extends {
25
34
  id: string;
@@ -1,12 +1,12 @@
1
1
  "use client";
2
- import { useState, useCallback } from "react";
2
+ import { useState, useCallback, useEffect, useRef } from "react";
3
3
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
4
4
  import { useBulkSelection } from "../../../react/hooks/useBulkSelection";
5
5
  import { usePanelUrlSync } from "../../../react/hooks/use-panel-url-sync";
6
6
  import { useDataViewMode } from "../../account/hooks/useDataViewMode";
7
7
  import { useAdminListingData } from "./useAdminListingData";
8
8
  export function useAdminListing(config) {
9
- const { filterKeys, defaultSort, pageSize: defaultPageSize = 25, queryKey, endpoint, mapRows, getTotal, buildFilters, buildExtraParams, filterDefaults = {}, } = config;
9
+ const { filterKeys, defaultSort, pageSize: defaultPageSize = 25, queryKey, endpoint, mapRows, getTotal, buildFilters, buildExtraParams, filterDefaults = {}, searchCommit = "enter", searchDebounceMs = 350, } = config;
10
10
  // Persisted, viewport-aware view-mode: below 768px defaults to "list"
11
11
  // (AdminViewCards' one-full-width-card-per-row layout) unless the caller
12
12
  // explicitly pins hideTableView (grid-only views like coupons); the user's
@@ -53,6 +53,34 @@ export function useAdminListing(config) {
53
53
  const commitSearch = useCallback(() => {
54
54
  table.set("q", searchInput.trim());
55
55
  }, [searchInput, table]);
56
+ // Debounced commit, opt-in per view.
57
+ //
58
+ // Two things here are load-bearing:
59
+ //
60
+ // 1. It compares against what is ALREADY in the URL and skips a no-op.
61
+ // `table.set` issues a router.replace, and replacing the URL with the
62
+ // value it already holds re-runs the query for nothing — on mount, on
63
+ // every unrelated re-render, and on the render caused by the previous
64
+ // commit. That is the double-navigation shape of Root Cause #13.
65
+ //
66
+ // 2. The timer is cleared on every keystroke AND on unmount, so a term is
67
+ // never committed by a view the user has already navigated away from.
68
+ const debounceRef = useRef(null);
69
+ const committed = table.get("q") ?? "";
70
+ useEffect(() => {
71
+ if (searchCommit !== "debounce")
72
+ return;
73
+ const next = searchInput.trim();
74
+ if (next === committed)
75
+ return;
76
+ if (debounceRef.current)
77
+ clearTimeout(debounceRef.current);
78
+ debounceRef.current = setTimeout(() => table.set("q", next), searchDebounceMs);
79
+ return () => {
80
+ if (debounceRef.current)
81
+ clearTimeout(debounceRef.current);
82
+ };
83
+ }, [searchInput, committed, searchCommit, searchDebounceMs, table]);
56
84
  const activeFilterCount = filterKeys.filter((k) => !!table.get(k)).length;
57
85
  const hasActiveState = !!table.get("q") || table.get("sort") !== defaultSort || activeFilterCount > 0;
58
86
  const currentFilterState = Object.fromEntries(filterKeys.map((k) => [k, table.get(k)]));
@@ -6,7 +6,28 @@ export declare class UserRepository extends BaseRepository<UserDocument> {
6
6
  static readonly SIEVE_FIELDS: FirebaseSieveFields;
7
7
  constructor();
8
8
  private decryptUser;
9
- private encryptUserData;
9
+ /**
10
+ * Declared, not hand-rolled.
11
+ *
12
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
13
+ * `encryptUserData` did — encrypt the listed fields, then add the mapped blind
14
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
15
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
16
+ * `*InBatch` family that inherited unhooked base implementations while the
17
+ * encryption lived in a private method only three call sites remembered to
18
+ * call.
19
+ */
20
+ protected piiFields: readonly ["email", "phoneNumber", "googleLinkedEmail"];
21
+ protected piiIndexMap: Record<string, string>;
22
+ /**
23
+ * The base hook, plus the nested objects it cannot see.
24
+ *
25
+ * `piiFields` is a flat list of top-level names, so a nested blob has to be
26
+ * encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
27
+ * encryption path with one extra step on top, rather than two parallel paths
28
+ * that have to agree — which is what the hand-rolled override was.
29
+ */
30
+ protected applyWriteHooks<D extends object>(data: D): D;
10
31
  protected mapDoc<D = UserDocument>(snap: DocumentSnapshot): D;
11
32
  createWithId(id: string, data: Partial<UserDocument>): Promise<UserDocument>;
12
33
  create(input: Omit<UserDocument, "id" | "createdAt" | "updatedAt">): Promise<UserDocument>;
@@ -1,11 +1,24 @@
1
1
  import { normalizeError } from "../../../errors/normalize";
2
2
  import { DatabaseError } from "../../../errors";
3
3
  import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
4
- import { USER_PII_FIELDS, USER_PII_INDEX_MAP, piiIndicesFor, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptPiiFields, encryptShippingConfig, piiBlindIndex, } from "../../../security";
4
+ import { USER_PII_FIELDS, USER_PII_INDEX_MAP, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptShippingConfig, piiBlindIndex, } from "../../../security";
5
5
  import { USER_COLLECTION, USER_FIELDS, } from "../schemas";
6
6
  export class UserRepository extends BaseRepository {
7
7
  constructor() {
8
8
  super(USER_COLLECTION);
9
+ /**
10
+ * Declared, not hand-rolled.
11
+ *
12
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
13
+ * `encryptUserData` did — encrypt the listed fields, then add the mapped blind
14
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
15
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
16
+ * `*InBatch` family that inherited unhooked base implementations while the
17
+ * encryption lived in a private method only three call sites remembered to
18
+ * call.
19
+ */
20
+ this.piiFields = USER_PII_FIELDS;
21
+ this.piiIndexMap = USER_PII_INDEX_MAP;
9
22
  }
10
23
  decryptUser(doc) {
11
24
  const decrypted = decryptPiiFields(doc, [...USER_PII_FIELDS]);
@@ -17,32 +30,22 @@ export class UserRepository extends BaseRepository {
17
30
  }
18
31
  return decrypted;
19
32
  }
20
- encryptUserData(data) {
21
- // encryptPiiFields encrypts each PII field in-place AND adds the corresponding
22
- // blind-index sibling (e.g. email → emailIndex) from the plaintext value.
23
- // addPiiIndices is intentionally NOT called here — it spreads the original
24
- // plaintext `data` back into the result, which would overwrite the encrypted
25
- // ciphertext with the original plaintext values, defeating the encryption.
26
- //
27
- // The mapped indices below are not optional. `encryptPiiFields` derives its
28
- // sibling name mechanically as `${field}Index`, so `phoneNumber` produces
29
- // `phoneNumberIndex` — but `findByPhone` queries `USER_FIELDS.PHONE_INDEX`,
30
- // which is `phoneIndex`. Only the seed paths ever wrote that name, so
31
- // **phone lookup could never match a user created by the app.**
32
- // `piiIndicesFor` applies USER_PII_INDEX_MAP's real names and, unlike
33
- // `addPiiIndices`, carries no plaintext back over the ciphertext.
34
- const encrypted = {
35
- ...encryptPiiFields(data, [...USER_PII_FIELDS]),
36
- ...piiIndicesFor(data, USER_PII_INDEX_MAP),
37
- };
38
- const access = encrypted;
39
- if (access.payoutDetails) {
33
+ /**
34
+ * The base hook, plus the nested objects it cannot see.
35
+ *
36
+ * `piiFields` is a flat list of top-level names, so a nested blob has to be
37
+ * encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
38
+ * encryption path with one extra step on top, rather than two parallel paths
39
+ * that have to agree — which is what the hand-rolled override was.
40
+ */
41
+ applyWriteHooks(data) {
42
+ const out = super.applyWriteHooks(data);
43
+ const access = out;
44
+ if (access.payoutDetails)
40
45
  access.payoutDetails = encryptPayoutDetails(access.payoutDetails);
41
- }
42
- if (access.shippingConfig) {
46
+ if (access.shippingConfig)
43
47
  access.shippingConfig = encryptShippingConfig(access.shippingConfig);
44
- }
45
- return encrypted;
48
+ return out;
46
49
  }
47
50
  mapDoc(snap) {
48
51
  const raw = super.mapDoc(snap);
@@ -57,7 +60,7 @@ export class UserRepository extends BaseRepository {
57
60
  // check with no error anywhere (found via a live RBAC bug where an
58
61
  // admin account created this way couldn't access /admin).
59
62
  const withUid = { uid: id, ...data };
60
- const encrypted = this.encryptUserData(withUid);
63
+ const encrypted = this.applyWriteHooks(withUid);
61
64
  return super.createWithId(id, encrypted);
62
65
  }
63
66
  async create(input) {
@@ -78,7 +81,7 @@ export class UserRepository extends BaseRepository {
78
81
  createdAt: new Date(),
79
82
  updatedAt: new Date(),
80
83
  };
81
- const encrypted = this.encryptUserData(userData);
84
+ const encrypted = this.applyWriteHooks(userData);
82
85
  await this.db
83
86
  .collection(this.collection)
84
87
  .doc(id)
@@ -155,7 +158,7 @@ export class UserRepository extends BaseRepository {
155
158
  }
156
159
  }
157
160
  async update(uid, data) {
158
- const encrypted = this.encryptUserData(data);
161
+ const encrypted = this.applyWriteHooks(data);
159
162
  return super.update(uid, encrypted);
160
163
  }
161
164
  async markEmailAsVerified(uid) {
@@ -1,5 +1,4 @@
1
1
  import { type DocumentReference } from "firebase-admin/firestore";
2
- import type { DocumentSnapshot } from "../../../providers/db-firebase";
3
2
  import { BaseRepository, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
4
3
  import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type AddGroupLineInput, type UpdateCartItemInput, type UpdateCartGroupMembersInput } from "../schemas";
5
4
  export declare class CartRepository extends BaseRepository<CartDocument> {
@@ -7,7 +6,21 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
7
6
  constructor();
8
7
  /** Admin-facing paginated list of carts. */
9
8
  list(model: SieveModel): Promise<FirebaseSieveResult<CartDocument>>;
10
- protected mapDoc<D = CartDocument>(snap: DocumentSnapshot): D;
9
+ /**
10
+ * `mapDoc` is deliberately NOT overridden.
11
+ *
12
+ * It used to decrypt `items[].storeName` on every read — and nothing has
13
+ * ever encrypted that field. `CartRepository` declares no `piiFields`, and
14
+ * `storeName` appears nowhere in pii-schemas.ts. `decryptPii` passes a
15
+ * non-`enc:v1:` string straight through, so the call was a no-op on every
16
+ * document while reading as protection — the most expensive kind of dead
17
+ * code, because it answers the question "is this handled?" wrongly.
18
+ *
19
+ * A store name is not PII: it is the seller's public shopfront name, shown
20
+ * on every listing. If a genuinely private field is added to a cart line,
21
+ * declare it in `piiFields` so `applyWriteHooks` encrypts it on all nine
22
+ * write paths — do not reintroduce a decrypt with no matching encrypt.
23
+ */
11
24
  findByUserId(userId: string): Promise<CartDocument | null>;
12
25
  getOrCreate(userId: string): Promise<CartDocument>;
13
26
  createEmpty(userId: string): Promise<CartDocument>;
@@ -6,7 +6,6 @@ import { normalizeError } from "../../../errors/normalize";
6
6
  const ERR_CART_ITEM_NOT_FOUND = "Cart item not found";
7
7
  const ERR_CART_ITEM_LOCKED = "This item requires payment and cannot be removed or modified.";
8
8
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
9
- import { decryptPii } from "../../../security";
10
9
  import { CART_COLLECTION, } from "../schemas";
11
10
  export class CartRepository extends BaseRepository {
12
11
  constructor() {
@@ -16,18 +15,21 @@ export class CartRepository extends BaseRepository {
16
15
  async list(model) {
17
16
  return this.sieveQuery(model, CartRepository.SIEVE_FIELDS);
18
17
  }
19
- mapDoc(snap) {
20
- const raw = super.mapDoc(snap);
21
- return {
22
- ...raw,
23
- items: (raw.items ?? []).map((item) => ({
24
- ...item,
25
- storeName: typeof item.storeName === "string"
26
- ? (decryptPii(item.storeName) ?? item.storeName)
27
- : item.storeName,
28
- })),
29
- };
30
- }
18
+ /**
19
+ * `mapDoc` is deliberately NOT overridden.
20
+ *
21
+ * It used to decrypt `items[].storeName` on every read — and nothing has
22
+ * ever encrypted that field. `CartRepository` declares no `piiFields`, and
23
+ * `storeName` appears nowhere in pii-schemas.ts. `decryptPii` passes a
24
+ * non-`enc:v1:` string straight through, so the call was a no-op on every
25
+ * document while reading as protection — the most expensive kind of dead
26
+ * code, because it answers the question "is this handled?" wrongly.
27
+ *
28
+ * A store name is not PII: it is the seller's public shopfront name, shown
29
+ * on every listing. If a genuinely private field is added to a cart line,
30
+ * declare it in `piiFields` so `applyWriteHooks` encrypts it on all nine
31
+ * write paths — do not reintroduce a decrypt with no matching encrypt.
32
+ */
31
33
  async findByUserId(userId) {
32
34
  return this.findById(userId);
33
35
  }
@@ -34,7 +34,13 @@ export function AdminEventsView({ children, ...props }) {
34
34
  portal: "admin",
35
35
  title: "Events",
36
36
  // buildEventSearchTxt indexes title + description + type + tags.
37
- searchPlaceholder: "Search by title, description, type or tag…",
37
+ // buildEventSearchTxt indexes title + description + type + tags.
38
+ search: {
39
+ placeholder: "Search by title, description, type or tag…",
40
+ mode: "partial",
41
+ fields: ["title", "description", "type", "tags"],
42
+ commit: "debounce",
43
+ },
38
44
  emptyLabel: "No events found",
39
45
  filterKeys: ["status", "type"],
40
46
  defaultSort: sortBy("startsAt", "DESC"),
@@ -27,7 +27,18 @@ export interface OrderWriteContext {
27
27
  declare class OrderRepository extends BaseRepository<OrderDocument> {
28
28
  constructor();
29
29
  private decryptOrder;
30
- private encryptOrderData;
30
+ /**
31
+ * Declared, not hand-rolled.
32
+ *
33
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
34
+ * `encryptOrderData` did — encrypt the listed fields, then add the mapped blind
35
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
36
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
37
+ * `*InBatch` family that inherited unhooked base implementations while the
38
+ * encryption lived in a private method only three call sites remembered to
39
+ * call.
40
+ */
41
+ protected piiFields: readonly ["userName", "userEmail", "sellerEmail"];
31
42
  protected mapDoc<D = OrderDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
32
43
  create(input: OrderCreateInput): Promise<OrderDocument>;
33
44
  findByUser(userId: string): Promise<OrderDocument[]>;
@@ -3,7 +3,7 @@ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../uti
3
3
  import { normalizeError } from "../../../errors/normalize";
4
4
  import { DatabaseError, NotFoundError } from "../../../errors";
5
5
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
6
- import { decryptPiiFields, encryptPiiFields, ORDER_PII_FIELDS, } from "../../../security";
6
+ import { decryptPiiFields, ORDER_PII_FIELDS, } from "../../../security";
7
7
  import { serverTimestamp, arrayUnion } from "../../../contracts/field-ops";
8
8
  import { createOrderId, ORDER_COLLECTION, ORDER_TRACKED_FIELDS, OrderStatusValues, } from "../schemas";
9
9
  import { withHistory } from "../../../_internal/shared/history/index";
@@ -24,15 +24,24 @@ const ACTIVE_ALLOWANCE_STATUSES = [
24
24
  class OrderRepository extends BaseRepository {
25
25
  constructor() {
26
26
  super(ORDER_COLLECTION);
27
+ /**
28
+ * Declared, not hand-rolled.
29
+ *
30
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
31
+ * `encryptOrderData` did — encrypt the listed fields, then add the mapped blind
32
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
33
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
34
+ * `*InBatch` family that inherited unhooked base implementations while the
35
+ * encryption lived in a private method only three call sites remembered to
36
+ * call.
37
+ */
38
+ this.piiFields = ORDER_PII_FIELDS;
27
39
  }
28
40
  decryptOrder(doc) {
29
41
  return decryptPiiFields(doc, [
30
42
  ...ORDER_PII_FIELDS,
31
43
  ]);
32
44
  }
33
- encryptOrderData(data) {
34
- return encryptPiiFields(data, [...ORDER_PII_FIELDS]);
35
- }
36
45
  mapDoc(snap) {
37
46
  const raw = super.mapDoc(snap);
38
47
  return this.decryptOrder(raw);
@@ -46,7 +55,7 @@ class OrderRepository extends BaseRepository {
46
55
  createdAt: new Date(),
47
56
  updatedAt: new Date(),
48
57
  };
49
- const encrypted = this.encryptOrderData(orderData);
58
+ const encrypted = this.applyWriteHooks(orderData);
50
59
  await this.db
51
60
  .collection(this.collection)
52
61
  .doc(id)