@mohasinac/appkit 4.30.15 → 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 (27) 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/auth/repository/user.repository.d.ts +22 -1
  6. package/dist/features/auth/repository/user.repository.js +31 -28
  7. package/dist/features/cart/repository/cart.repository.d.ts +15 -2
  8. package/dist/features/cart/repository/cart.repository.js +15 -13
  9. package/dist/features/orders/repository/orders.repository.d.ts +12 -1
  10. package/dist/features/orders/repository/orders.repository.js +14 -5
  11. package/dist/features/payments/repository/payout.repository.d.ts +19 -9
  12. package/dist/features/payments/repository/payout.repository.js +26 -20
  13. package/dist/features/products/components/ProductFilters.d.ts +19 -1
  14. package/dist/features/products/components/ProductFilters.js +11 -3
  15. package/dist/features/products/components/ProductsIndexListing.d.ts +4 -2
  16. package/dist/features/products/components/ProductsIndexListing.js +40 -27
  17. package/dist/features/reviews/repository/reviews.repository.d.ts +10 -8
  18. package/dist/features/reviews/repository/reviews.repository.js +16 -19
  19. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +161 -17
  20. package/dist/providers/db-firebase/base.repository.d.ts +12 -0
  21. package/dist/providers/db-firebase/base.repository.js +14 -2
  22. package/dist/react/hooks/usePendingFilters.d.ts +12 -2
  23. package/dist/react/hooks/usePendingFilters.js +2 -2
  24. package/dist/react/hooks/usePendingTable.d.ts +9 -2
  25. package/dist/security/pii-schemas.d.ts +19 -1
  26. package/dist/security/pii-schemas.js +19 -1
  27. package/package.json +1 -1
@@ -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,
@@ -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
  }
@@ -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)
@@ -20,17 +20,27 @@ export declare class PayoutRepository extends BaseRepository<PayoutDocument> {
20
20
  /** Decrypt PII fields on a payout document after Firestore read */
21
21
  private decryptPayout;
22
22
  /**
23
- * Encrypt PII, then attach blind indices derived from the plaintext.
23
+ * Declared, not hand-rolled.
24
24
  *
25
- * Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
26
- * re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
27
- * then spread that over the ciphertext, so plaintext won and `sellerEmail`
28
- * and `upiId` were written to Firestore in the clear beside a valid index.
29
- * `piiIndicesFor` returns the index fields alone, so the ciphertext cannot be
30
- * overwritten. The identical bug was fixed in UserRepository and never
31
- * propagated here.
25
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
26
+ * `encryptPayoutData` did — encrypt the listed fields, then add the mapped blind
27
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
28
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
29
+ * `*InBatch` family that inherited unhooked base implementations while the
30
+ * encryption lived in a private method only three call sites remembered to
31
+ * call.
32
32
  */
33
- private encryptPayoutData;
33
+ protected piiFields: readonly ["sellerEmail", "upiId"];
34
+ protected piiIndexMap: Record<string, string>;
35
+ /**
36
+ * The base hook, plus the nested objects it cannot see.
37
+ *
38
+ * `piiFields` is a flat list of top-level names, so a nested blob has to be
39
+ * encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
40
+ * encryption path with one extra step on top, rather than two parallel paths
41
+ * that have to agree — which is what the hand-rolled override was.
42
+ */
43
+ protected applyWriteHooks<D extends object>(data: D): D;
34
44
  /** Override mapDoc to auto-decrypt PII on every Firestore read */
35
45
  protected mapDoc<D = PayoutDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
36
46
  /**
@@ -7,10 +7,23 @@
7
7
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
8
8
  import { PAYOUT_COLLECTION, PAYOUT_FIELDS, PAYOUT_TRACKED_FIELDS, createPayoutId } from "../schemas";
9
9
  import { withHistory } from "../../../_internal/shared/history/index";
10
- import { encryptPiiFields, decryptPiiFields, piiIndicesFor, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "../../../security";
10
+ import { decryptPiiFields, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "../../../security";
11
11
  export class PayoutRepository extends BaseRepository {
12
12
  constructor() {
13
13
  super(PAYOUT_COLLECTION);
14
+ /**
15
+ * Declared, not hand-rolled.
16
+ *
17
+ * `BaseRepository.applyWriteHooks` already does exactly what the private
18
+ * `encryptPayoutData` did — encrypt the listed fields, then add the mapped blind
19
+ * indices derived from the PLAINTEXT source. Declaring the fields means all
20
+ * nine mutating methods get it, including the `createWithId` / `*InTx` /
21
+ * `*InBatch` family that inherited unhooked base implementations while the
22
+ * encryption lived in a private method only three call sites remembered to
23
+ * call.
24
+ */
25
+ this.piiFields = PAYOUT_PII_FIELDS;
26
+ this.piiIndexMap = PAYOUT_PII_INDEX_MAP;
14
27
  }
15
28
  /** Decrypt PII fields on a payout document after Firestore read */
16
29
  decryptPayout(doc) {
@@ -21,26 +34,19 @@ export class PayoutRepository extends BaseRepository {
21
34
  return decrypted;
22
35
  }
23
36
  /**
24
- * Encrypt PII, then attach blind indices derived from the plaintext.
37
+ * The base hook, plus the nested objects it cannot see.
25
38
  *
26
- * Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
27
- * re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
28
- * then spread that over the ciphertext, so plaintext won and `sellerEmail`
29
- * and `upiId` were written to Firestore in the clear beside a valid index.
30
- * `piiIndicesFor` returns the index fields alone, so the ciphertext cannot be
31
- * overwritten. The identical bug was fixed in UserRepository and never
32
- * propagated here.
39
+ * `piiFields` is a flat list of top-level names, so a nested blob has to be
40
+ * encrypted separately. Calling `super.applyWriteHooks` FIRST keeps a single
41
+ * encryption path with one extra step on top, rather than two parallel paths
42
+ * that have to agree — which is what the hand-rolled override was.
33
43
  */
34
- encryptPayoutData(data) {
35
- const encrypted = {
36
- ...encryptPiiFields(data, [...PAYOUT_PII_FIELDS]),
37
- ...piiIndicesFor(data, PAYOUT_PII_INDEX_MAP),
38
- };
39
- const access = encrypted;
40
- if (access.bankAccount) {
44
+ applyWriteHooks(data) {
45
+ const out = super.applyWriteHooks(data);
46
+ const access = out;
47
+ if (access.bankAccount)
41
48
  access.bankAccount = encryptPayoutBankAccount(access.bankAccount);
42
- }
43
- return encrypted;
49
+ return out;
44
50
  }
45
51
  /** Override mapDoc to auto-decrypt PII on every Firestore read */
46
52
  mapDoc(snap) {
@@ -61,7 +67,7 @@ export class PayoutRepository extends BaseRepository {
61
67
  updatedAt: now,
62
68
  };
63
69
  // Encrypt PII before persisting
64
- const encrypted = this.encryptPayoutData(data);
70
+ const encrypted = this.applyWriteHooks(data);
65
71
  await this.db
66
72
  .collection(this.collection)
67
73
  .doc(id)
@@ -70,7 +76,7 @@ export class PayoutRepository extends BaseRepository {
70
76
  }
71
77
  /** Override base update to preserve encryption/indexing for changed PII fields. */
72
78
  async update(payoutId, data) {
73
- const encrypted = this.encryptPayoutData(data);
79
+ const encrypted = this.applyWriteHooks(data);
74
80
  return super.update(payoutId, encrypted);
75
81
  }
76
82
  /**
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  import type { FacetOption } from "../../filters/FilterFacetSection";
2
3
  import type { UrlTable } from "../../filters/FilterPanel";
3
4
  import type { AsyncFacetSectionProps } from "../../filters/AsyncFacetSection";
@@ -115,6 +116,23 @@ export interface ProductFiltersProps {
115
116
  sublistingCategoryOptions?: FacetOption[];
116
117
  /** Feature badge options for filtering by product features */
117
118
  featureOptions?: FacetOption[];
119
+ /**
120
+ * Listing-type facet options — `{ value: <canonical ListingType>, label }`.
121
+ *
122
+ * Opt-in: only the combined browse pages (`/products`, `/art`) span more than
123
+ * one listing type, so every other caller leaves this empty and the section
124
+ * does not render. Derive the options from `PRODUCT_TYPE_FILTER_TABS` /
125
+ * `ART_STICKERS_TYPE_FILTER_TABS` — never a hand-written list, which is what
126
+ * `audit-listing-type-tab-coverage.mjs` exists to prevent (Root Cause #61).
127
+ */
128
+ listingTypeOptions?: FacetOption[];
129
+ /**
130
+ * Rendered directly beneath the listing-type section. The caller owns it so
131
+ * this shared component does not have to know about the listing-type plugin
132
+ * registry — `/products` uses it for the "Full <type> filters →" link to a
133
+ * single selected type's dedicated browse page.
134
+ */
135
+ listingTypeFooter?: ReactNode;
118
136
  showStatus?: boolean;
119
137
  variant?: ProductFilterVariant;
120
138
  statusOptions?: FacetOption[];
@@ -124,4 +142,4 @@ export interface ProductFiltersProps {
124
142
  /** Show "part of a bundle" switch filter (default true) */
125
143
  showBundleFilter?: boolean;
126
144
  }
127
- export declare function ProductFilters({ table, categoryOptions, brandOptions, storeOptions, sellerOptions, tagOptions, sublistingCategoryOptions, featureOptions, showStatus, variant, statusOptions, currencyPrefix, showShipping, showBundleFilter, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }: ProductFiltersProps): import("react").JSX.Element;
145
+ export declare function ProductFilters({ table, categoryOptions, brandOptions, storeOptions, sellerOptions, tagOptions, sublistingCategoryOptions, featureOptions, listingTypeOptions, listingTypeFooter, showStatus, variant, statusOptions, currencyPrefix, showShipping, showBundleFilter, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }: ProductFiltersProps): import("react").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useTranslations } from "next-intl";
4
4
  import { FilterFacetSection } from "../../filters/FilterFacetSection";
5
5
  import { AsyncFacetSection } from "../../filters/AsyncFacetSection";
@@ -66,7 +66,7 @@ export function getProductSortOptions(variant) {
66
66
  return PRODUCT_PUBLIC_SORT_OPTIONS;
67
67
  }
68
68
  }
69
- export function ProductFilters({ table, categoryOptions = [], brandOptions = [], storeOptions = [], sellerOptions, tagOptions = [], sublistingCategoryOptions = [], featureOptions = [], showStatus = false, variant, statusOptions, currencyPrefix = "", showShipping = true, showBundleFilter = true, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }) {
69
+ export function ProductFilters({ table, categoryOptions = [], brandOptions = [], storeOptions = [], sellerOptions, tagOptions = [], sublistingCategoryOptions = [], featureOptions = [], listingTypeOptions = [], listingTypeFooter, showStatus = false, variant, statusOptions, currencyPrefix = "", showShipping = true, showBundleFilter = true, loadCategoryOptions, loadBrandOptions, loadStoreOptions, }) {
70
70
  const resolvedStoreOptions = storeOptions.length > 0 ? storeOptions : (sellerOptions ?? []);
71
71
  const t = useTranslations("filters");
72
72
  const conditionOptions = [
@@ -99,5 +99,13 @@ export function ProductFilters({ table, categoryOptions = [], brandOptions = [],
99
99
  const selectedStatuses = splitPipe(TABLE_KEYS.STATUS);
100
100
  const selectedSublistings = splitPipe(TABLE_KEYS.SUBLISTING_CATEGORY);
101
101
  const selectedFeatures = splitPipe(TABLE_KEYS.FEATURES);
102
- return (_jsxs(Div, { children: [loadCategoryOptions ? (_jsx(AsyncFacetSection, { title: t("category"), loadOptions: loadCategoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), defaultCollapsed: false })) : categoryOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("category"), options: categoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), searchable: true, defaultCollapsed: categoryOptions.length > 6 })) : null, _jsx(FilterFacetSection, { title: t("condition"), options: conditionOptions, selected: selectedConditions, onChange: (vals) => table.set(TABLE_KEYS.CONDITION, vals.join("|")), searchable: false, defaultCollapsed: false }), _jsx(RangeFilter, { title: t("priceRange"), minValue: table.get(TABLE_KEYS.MIN_PRICE), maxValue: table.get(TABLE_KEYS.MAX_PRICE), onMinChange: (v) => table.set(TABLE_KEYS.MIN_PRICE, v), onMaxChange: (v) => table.set(TABLE_KEYS.MAX_PRICE, v), prefix: currencyPrefix, showSlider: true, minBound: 0, maxBound: 500000, step: 500, minPlaceholder: t("minPrice"), maxPlaceholder: t("maxPrice"), defaultCollapsed: false }), loadBrandOptions ? (_jsx(AsyncFacetSection, { title: t("brand"), loadOptions: loadBrandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), defaultCollapsed: false })) : brandOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), searchable: brandOptions.length > 4, defaultCollapsed: brandOptions.length > 6 })) : null, loadStoreOptions ? (_jsx(AsyncFacetSection, { title: t("store"), loadOptions: loadStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), defaultCollapsed: false })) : resolvedStoreOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("store"), options: resolvedStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), searchable: resolvedStoreOptions.length > 4, defaultCollapsed: resolvedStoreOptions.length > 6 })) : null, showShipping && (_jsx(SwitchFilter, { title: t("shipping"), label: t("freeShippingOnly"), checked: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (v) => table.set(TABLE_KEYS.FREE_SHIPPING, v ? "true" : ""), defaultCollapsed: false })), tagOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("tags"), options: tagOptions, selected: selectedTags, onChange: (vals) => table.set(TABLE_KEYS.TAGS, vals.join("|")), searchable: tagOptions.length > 4, defaultCollapsed: tagOptions.length > 6 })), shouldShowStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions ?? defaultStatusOptions, selected: selectedStatuses, onChange: (vals) => table.set(TABLE_KEYS.STATUS, vals.join("|")), searchable: false, defaultCollapsed: false })), sublistingCategoryOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Sublisting Type", options: sublistingCategoryOptions, selected: selectedSublistings, onChange: (vals) => table.set(TABLE_KEYS.SUBLISTING_CATEGORY, vals.join("|")), searchable: sublistingCategoryOptions.length > 4, defaultCollapsed: false })), showBundleFilter && (_jsx(SwitchFilter, { title: "Bundles", label: "Part of a bundle only", checked: table.get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true", onChange: (v) => table.set(TABLE_KEYS.IS_PART_OF_BUNDLE, v ? "true" : ""), defaultCollapsed: false })), featureOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Features", options: featureOptions, selected: selectedFeatures, onChange: (vals) => table.set(TABLE_KEYS.FEATURES, vals.join("|")), searchable: featureOptions.length > 4, defaultCollapsed: featureOptions.length > 4 }))] }));
102
+ const selectedListingTypes = splitPipe(TABLE_KEYS.LISTING_TYPE);
103
+ return (_jsxs(Div, { children: [listingTypeOptions.length > 0 && (_jsxs(_Fragment, { children: [_jsx(FilterFacetSection, { title: "Listing type", options: listingTypeOptions, selected: selectedListingTypes, onChange: (vals) =>
104
+ // Serialise in the OPTIONS' order, not the order the boxes were
105
+ // ticked — `auction|art` and `art|auction` are the same result
106
+ // set but two different React Query keys and two different URLs.
107
+ table.set(TABLE_KEYS.LISTING_TYPE, listingTypeOptions
108
+ .filter((o) => vals.includes(o.value))
109
+ .map((o) => o.value)
110
+ .join("|")), searchable: false, defaultCollapsed: false }), listingTypeFooter] })), loadCategoryOptions ? (_jsx(AsyncFacetSection, { title: t("category"), loadOptions: loadCategoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), defaultCollapsed: false })) : categoryOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("category"), options: categoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), searchable: true, defaultCollapsed: categoryOptions.length > 6 })) : null, _jsx(FilterFacetSection, { title: t("condition"), options: conditionOptions, selected: selectedConditions, onChange: (vals) => table.set(TABLE_KEYS.CONDITION, vals.join("|")), searchable: false, defaultCollapsed: false }), _jsx(RangeFilter, { title: t("priceRange"), minValue: table.get(TABLE_KEYS.MIN_PRICE), maxValue: table.get(TABLE_KEYS.MAX_PRICE), onMinChange: (v) => table.set(TABLE_KEYS.MIN_PRICE, v), onMaxChange: (v) => table.set(TABLE_KEYS.MAX_PRICE, v), prefix: currencyPrefix, showSlider: true, minBound: 0, maxBound: 500000, step: 500, minPlaceholder: t("minPrice"), maxPlaceholder: t("maxPrice"), defaultCollapsed: false }), loadBrandOptions ? (_jsx(AsyncFacetSection, { title: t("brand"), loadOptions: loadBrandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), defaultCollapsed: false })) : brandOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), searchable: brandOptions.length > 4, defaultCollapsed: brandOptions.length > 6 })) : null, loadStoreOptions ? (_jsx(AsyncFacetSection, { title: t("store"), loadOptions: loadStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), defaultCollapsed: false })) : resolvedStoreOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("store"), options: resolvedStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), searchable: resolvedStoreOptions.length > 4, defaultCollapsed: resolvedStoreOptions.length > 6 })) : null, showShipping && (_jsx(SwitchFilter, { title: t("shipping"), label: t("freeShippingOnly"), checked: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (v) => table.set(TABLE_KEYS.FREE_SHIPPING, v ? "true" : ""), defaultCollapsed: false })), tagOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("tags"), options: tagOptions, selected: selectedTags, onChange: (vals) => table.set(TABLE_KEYS.TAGS, vals.join("|")), searchable: tagOptions.length > 4, defaultCollapsed: tagOptions.length > 6 })), shouldShowStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions ?? defaultStatusOptions, selected: selectedStatuses, onChange: (vals) => table.set(TABLE_KEYS.STATUS, vals.join("|")), searchable: false, defaultCollapsed: false })), sublistingCategoryOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Sublisting Type", options: sublistingCategoryOptions, selected: selectedSublistings, onChange: (vals) => table.set(TABLE_KEYS.SUBLISTING_CATEGORY, vals.join("|")), searchable: sublistingCategoryOptions.length > 4, defaultCollapsed: false })), showBundleFilter && (_jsx(SwitchFilter, { title: "Bundles", label: "Part of a bundle only", checked: table.get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true", onChange: (v) => table.set(TABLE_KEYS.IS_PART_OF_BUNDLE, v ? "true" : ""), defaultCollapsed: false })), featureOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Features", options: featureOptions, selected: selectedFeatures, onChange: (vals) => table.set(TABLE_KEYS.FEATURES, vals.join("|")), searchable: featureOptions.length > 4, defaultCollapsed: featureOptions.length > 4 }))] }));
103
111
  }
@@ -6,8 +6,10 @@ export interface ProductsIndexListingProps {
6
6
  * (e.g. `["art", "stickers"]`) to reuse this component for a different
7
7
  * combined browse page. */
8
8
  listingTypes?: readonly string[];
9
- /** Type-filter chip tabs shown above the grid. Defaults to the generic
10
- * Products tabs; pass a matching set when overriding `listingTypes`. */
9
+ /** Options for the "Listing type" checkbox facet in the filter drawer. `id`
10
+ * must be the canonical `ListingType` — it is passed straight into
11
+ * `sieveFilter("listingType", EQ, id)`. Defaults to the generic Products set;
12
+ * pass a matching set when overriding `listingTypes`. */
11
13
  typeTabs?: readonly {
12
14
  id: string;
13
15
  label: string;