@mohasinac/appkit 4.38.0 → 4.39.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 (53) hide show
  1. package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
  2. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
  3. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
  4. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
  5. package/dist/_internal/server/features/checkout/actions.js +18 -0
  6. package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
  7. package/dist/_internal/server/features/digital-code/download.js +128 -0
  8. package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
  9. package/dist/_internal/server/features/digital-code/pool.js +208 -0
  10. package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
  11. package/dist/_internal/server/features/digital-code/upload.js +144 -0
  12. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
  13. package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
  14. package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
  15. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
  16. package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
  17. package/dist/_internal/server/notifications/send-recorder.js +126 -0
  18. package/dist/client.d.ts +2 -0
  19. package/dist/client.js +6 -0
  20. package/dist/features/blog/components/BlogIndexPageView.js +10 -1
  21. package/dist/features/blog/repository/blog.repository.d.ts +14 -0
  22. package/dist/features/blog/repository/blog.repository.js +23 -1
  23. package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
  24. package/dist/features/categories/components/CategoryTree.js +1 -1
  25. package/dist/features/categories/repository/categories.repository.d.ts +34 -2
  26. package/dist/features/categories/repository/categories.repository.js +31 -9
  27. package/dist/features/contact/email.js +36 -1
  28. package/dist/features/orders/repository/orders.repository.d.ts +21 -1
  29. package/dist/features/orders/repository/orders.repository.js +26 -9
  30. package/dist/features/products/repository/products.repository.d.ts +35 -0
  31. package/dist/features/products/repository/products.repository.js +116 -3
  32. package/dist/features/products/schemas/firestore.d.ts +37 -0
  33. package/dist/features/reviews/repository/reviews.repository.d.ts +1 -1
  34. package/dist/features/reviews/repository/reviews.repository.js +2 -2
  35. package/dist/features/seller/actions/seller-actions.js +4 -13
  36. package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
  37. package/dist/features/seller/components/SellerProductShell.js +11 -2
  38. package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
  39. package/dist/features/stores/api/[storeSlug]/reviews/route.js +134 -38
  40. package/dist/features/stores/components/StoresIndexPageView.js +17 -1
  41. package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
  42. package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
  43. package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
  44. package/dist/features/tester/utils/phases.js +78 -12
  45. package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
  46. package/dist/providers/db-firebase/rtdb-paths.js +7 -0
  47. package/dist/server.d.ts +3 -0
  48. package/dist/server.js +12 -0
  49. package/dist/ui/components/FilePickerButton.d.ts +32 -0
  50. package/dist/ui/components/FilePickerButton.js +23 -0
  51. package/dist/ui/index.d.ts +2 -0
  52. package/dist/ui/index.js +7 -0
  53. package/package.json +1 -1
@@ -382,20 +382,42 @@ export class CategoriesRepository extends BaseRepository {
382
382
  }
383
383
  }
384
384
  /**
385
- * Cloud Functions: full-overwrite category metrics (used by nightly reconciliation job).
385
+ * Cloud Functions: full-overwrite category metrics (nightly reconciliation).
386
+ *
387
+ * 🛑 THE OWN COUNT AND THE ROLLUP ARE SEPARATE ARGUMENTS, and they must be.
388
+ *
389
+ * This method used to take ONE pair of numbers and write it to both
390
+ * `productCount` and `totalProductCount` — so it could not express the
391
+ * distinction the live trigger (`updateMetricsInBatch`, just above) carefully
392
+ * maintains: `productCount` is the category's OWN items, `totalProductCount`
393
+ * is own + every descendant's. The nightly job therefore overwrote correct
394
+ * values with wrong ones every night:
395
+ *
396
+ * - on a leaf it was accidentally right (a leaf has no descendants);
397
+ * - on an ancestor it wrote the DESCENDANT sum into both, so a category
398
+ * with 5 of its own and 15 below reported `productCount: 15` (should be
399
+ * 5) and `totalProductCount: 15` (should be 20 — its own 5 were never
400
+ * added, because the caller's aggregate only walked other rows' leaves);
401
+ * - on a row that is both a leaf and an ancestor, `setMetrics` was called
402
+ * twice and last-write-won.
403
+ *
404
+ * Measured on production 2026-09-14 before the fix: **19 of 65 category rows
405
+ * disagreed with a recount**, every one of them low — the root read 56 against
406
+ * a true 65. Nothing errored, and no page looked broken; a wrong number is
407
+ * simply a number.
386
408
  */
387
- async setMetrics(categoryId, productCount, auctionCount, productIds, auctionIds) {
409
+ async setMetrics(categoryId, m) {
388
410
  await this.db
389
411
  .collection(this.collection)
390
412
  .doc(categoryId)
391
413
  .update({
392
- "metrics.productCount": productCount,
393
- "metrics.auctionCount": auctionCount,
394
- "metrics.totalProductCount": productCount,
395
- "metrics.totalAuctionCount": auctionCount,
396
- "metrics.totalItemCount": productCount + auctionCount,
397
- "metrics.productIds": productIds,
398
- "metrics.auctionIds": auctionIds,
414
+ "metrics.productCount": m.productCount,
415
+ "metrics.auctionCount": m.auctionCount,
416
+ "metrics.totalProductCount": m.totalProductCount,
417
+ "metrics.totalAuctionCount": m.totalAuctionCount,
418
+ "metrics.totalItemCount": m.totalProductCount + m.totalAuctionCount,
419
+ "metrics.productIds": m.productIds,
420
+ "metrics.auctionIds": m.auctionIds,
399
421
  "metrics.lastUpdated": new Date(),
400
422
  updatedAt: new Date(),
401
423
  });
@@ -13,6 +13,7 @@ function renderToStaticMarkup(el) {
13
13
  }
14
14
  import { normalizeError } from "../../errors/normalize";
15
15
  import { guardSend, logSuppressedSend } from "../../_internal/server/notifications/send-guard";
16
+ import { recordSendAttempt } from "../../_internal/server/notifications/send-recorder";
16
17
  import { getProviders } from "../../contracts";
17
18
  import { createResendProvider } from "../../providers/email-resend/provider";
18
19
  import { siteSettingsRepository } from "../admin/repository/site-settings.repository";
@@ -94,6 +95,21 @@ export async function sendEmail(opts, guard) {
94
95
  const decision = await guardSend(guard);
95
96
  if (!decision.allow) {
96
97
  logSuppressedSend(guard, decision);
98
+ /*
99
+ * Record the SUPPRESSION, not just the sends. Six checklist cases assert that
100
+ * an email should NOT arrive, and over IMAP that is unprovable — you wait and
101
+ * hope. Here it is a row saying which rule stopped it. Gated to the harness
102
+ * mailbox; a no-op for everyone else. See send-recorder.ts.
103
+ */
104
+ void recordSendAttempt({
105
+ to: opts.to,
106
+ subject: typeof opts.subject === "string" ? opts.subject : "",
107
+ feature: guard.feature,
108
+ audience: String(guard.audience),
109
+ channel: String(guard.channel),
110
+ outcome: "suppressed",
111
+ reason: decision.reason,
112
+ });
97
113
  return { data: null, error: null, suppressed: decision.reason };
98
114
  }
99
115
  try {
@@ -112,11 +128,30 @@ export async function sendEmail(opts, guard) {
112
128
  ? opts.headers
113
129
  : undefined,
114
130
  });
131
+ void recordSendAttempt({
132
+ to: opts.to,
133
+ subject: typeof opts.subject === "string" ? opts.subject : "",
134
+ feature: guard.feature,
135
+ audience: String(guard.audience),
136
+ channel: String(guard.channel),
137
+ outcome: "sent",
138
+ html: typeof opts.html === "string" ? opts.html : undefined,
139
+ });
115
140
  return { data: data, error: null };
116
141
  }
117
142
  catch (error) {
118
143
  void normalizeError(error);
119
- return { data: null, error: error instanceof Error ? error.message : String(error) };
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ void recordSendAttempt({
146
+ to: opts.to,
147
+ subject: typeof opts.subject === "string" ? opts.subject : "",
148
+ feature: guard.feature,
149
+ audience: String(guard.audience),
150
+ channel: String(guard.channel),
151
+ outcome: "failed",
152
+ reason: message,
153
+ });
154
+ return { data: null, error: message };
120
155
  }
121
156
  }
122
157
  async function sendConfiguredEmail(opts, guard) {
@@ -162,7 +162,27 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
162
162
  canSort: boolean;
163
163
  };
164
164
  };
165
- listForSeller(productIds: string[], model: SieveModel, opts?: {
165
+ /**
166
+ * A store's orders.
167
+ *
168
+ * 🛑 SCOPED BY `storeId`, NOT BY A LIST OF PRODUCT IDS.
169
+ *
170
+ * It used to take the seller's whole product list and run
171
+ * `.where(productId, "in", productIds)`. **Firestore caps `in` at 30 values**
172
+ * and nothing bounded that array, so the query threw `INVALID_ARGUMENT` and
173
+ * the route 500'd for any store past its 30th listing. Measured on production
174
+ * 2026-09-14: `store-beyblade-arena` has **65** products, so
175
+ * `GET /api/store/orders` — the seller's own order list — was permanently
176
+ * broken for the only real seller on the site, while the two small stores
177
+ * worked fine. A seller with 29 listings would never have reported it.
178
+ *
179
+ * `storeId` is the right key regardless of the cap: it is the field a cart is
180
+ * SPLIT on (one order per store), `findFulfillmentQueue` a few lines up
181
+ * already queries it, it is a registered sieve field, and 64/64 production
182
+ * orders carry it. It also deletes an entire products query per request from
183
+ * all four call sites — the route already resolved the store.
184
+ */
185
+ listForSeller(storeId: string, model: SieveModel, opts?: {
166
186
  search?: string;
167
187
  }): Promise<FirebaseSieveResult<OrderDocument>>;
168
188
  listForUser(userId: string, model: SieveModel): Promise<FirebaseSieveResult<OrderDocument>>;
@@ -269,13 +269,33 @@ class OrderRepository extends BaseRepository {
269
269
  throw new DatabaseError(`Failed to delete orders for user: ${userId}`, error);
270
270
  }
271
271
  }
272
- async listForSeller(productIds, model, opts) {
272
+ /**
273
+ * A store's orders.
274
+ *
275
+ * 🛑 SCOPED BY `storeId`, NOT BY A LIST OF PRODUCT IDS.
276
+ *
277
+ * It used to take the seller's whole product list and run
278
+ * `.where(productId, "in", productIds)`. **Firestore caps `in` at 30 values**
279
+ * and nothing bounded that array, so the query threw `INVALID_ARGUMENT` and
280
+ * the route 500'd for any store past its 30th listing. Measured on production
281
+ * 2026-09-14: `store-beyblade-arena` has **65** products, so
282
+ * `GET /api/store/orders` — the seller's own order list — was permanently
283
+ * broken for the only real seller on the site, while the two small stores
284
+ * worked fine. A seller with 29 listings would never have reported it.
285
+ *
286
+ * `storeId` is the right key regardless of the cap: it is the field a cart is
287
+ * SPLIT on (one order per store), `findFulfillmentQueue` a few lines up
288
+ * already queries it, it is a registered sieve field, and 64/64 production
289
+ * orders carry it. It also deletes an entire products query per request from
290
+ * all four call sites — the route already resolved the store.
291
+ */
292
+ async listForSeller(storeId, model, opts) {
273
293
  const plan = planSearchTxt(opts?.search);
274
294
  // A search that yielded no usable term must return NOTHING, not the
275
295
  // seller's whole order list.
276
296
  if (plan.empty)
277
297
  return emptySearchResult();
278
- if (productIds.length === 0) {
298
+ if (!storeId) {
279
299
  const page = Math.max(1, Number(model.page ?? 1));
280
300
  const pageSize = Math.max(1, Number(model.pageSize ?? 20));
281
301
  return {
@@ -287,14 +307,11 @@ class OrderRepository extends BaseRepository {
287
307
  hasMore: false,
288
308
  };
289
309
  }
290
- let baseQuery = this.getCollection().where(ORDER_FIELDS.PRODUCT_ID, "in", productIds);
310
+ let baseQuery = this.getCollection().where(ORDER_FIELDS.STORE_ID, "==", storeId);
291
311
  if (plan.head) {
292
- // `array-contains` alongside `in` is legal — Firestore's one-per-query
293
- // limits are on `array-contains` and on the `in`/`not-in`/
294
- // `array-contains-any` family separately, so one of each is fine.
295
- // Verified against production data before this shipped, and the
296
- // matching composite index is declared in firestore.indexes.json;
297
- // the query happened to plan without one, which is not a guarantee.
312
+ // One `array-contains` alongside one equality is unconstrained —
313
+ // Firestore's per-query limits are on `array-contains` and on the
314
+ // `in`/`not-in`/`array-contains-any` family separately.
298
315
  baseQuery = baseQuery.where(ORDER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
299
316
  }
300
317
  const result = await this.sieveQuery(model, OrderRepository.SELLER_SIEVE_FIELDS, {
@@ -12,6 +12,41 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
12
12
  private cacheInvalidateForId;
13
13
  findById(id: string): Promise<ProductDocument | null>;
14
14
  protected mapDoc<D = ProductDocument>(snap: DocumentSnapshot): D;
15
+ /**
16
+ * Resolve a product's taxonomy into the shape every READ path expects:
17
+ * `categorySlugs` as the FULL ancestor chain (self first, root last), and a
18
+ * brand recorded as both its id and its display name.
19
+ *
20
+ * 🛑 WHY THIS LIVES IN THE REPOSITORY. The seller and admin create/update
21
+ * paths send a SINGLE category id (`category: "category-x-starters"`) — and
22
+ * `productCreateSchema` (src/validation/request-schemas.ts:129) declares only
23
+ * that scalar, with no `.passthrough()`, so an inbound `categorySlugs` array
24
+ * is stripped before it ever reaches Firestore. `mapDoc` then backfills
25
+ * `categorySlugs = [category]` **on read only**, which is invisible to a
26
+ * query: the category page runs `array-contains-any` against the STORED
27
+ * field, so a UI-created listing was unreachable from its own leaf category
28
+ * and from every ancestor of it. CLAUDE.md recorded the ancestor half of this
29
+ * as an outstanding follow-up; the leaf half was never noticed because every
30
+ * product in the database came from the seed, which hand-writes the chain
31
+ * (measured 2026-09-14: 95/95 products carried a correct chain, so the defect
32
+ * is LATENT — it fires the first time anyone creates a listing through the
33
+ * UI, which is precisely why no page looks broken today).
34
+ *
35
+ * There are ~14 independent write paths — two seller actions, two admin
36
+ * actions, four in `_internal/server/features/products/actions.ts`, the
37
+ * duplicate route, group-children, catalogue promotion, shipment linking and
38
+ * the WhatsApp import. Deriving at each one is Root Cause #75's shape: the
39
+ * copy made before a branch was added never learns about it. `create` and
40
+ * `update` are the only two methods every one of them funnels through.
41
+ *
42
+ * 🛑 It reads `categories` through `this.db` rather than importing
43
+ * `categoriesRepository`. No repository in this codebase imports another
44
+ * feature's repository, and the reason is the Turbopack import-chain trap
45
+ * (Root Cause #6/#18) — a raw collection read has neither problem.
46
+ */
47
+ private deriveTaxonomy;
48
+ /** The keys whose presence means a write could change the product's taxonomy. */
49
+ private static readonly TAXONOMY_KEYS;
15
50
  update(id: string, data: Partial<ProductDocument>): Promise<ProductDocument>;
16
51
  create(input: ProductCreateInput): Promise<ProductDocument>;
17
52
  findByStore(storeId: string): Promise<ProductDocument[]>;
@@ -10,6 +10,9 @@ import { matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/sear
10
10
  // wrapper. Three copies existed and two were missing the same three fields.
11
11
  import { buildProductSearchTxt } from "../../../utils/search-txt-builders";
12
12
  import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas";
13
+ // Collection NAME only — deliberately not `categoriesRepository`. See
14
+ // deriveTaxonomy()'s header for why this is a raw read.
15
+ import { CATEGORIES_COLLECTION } from "../../categories/schemas/firestore";
13
16
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
14
17
  /**
15
18
  * Canonical listing-kind tokens used in `listingType`. Note: the public Sieve
@@ -92,12 +95,109 @@ export class ProductRepository extends BaseRepository {
92
95
  }
93
96
  return doc;
94
97
  }
98
+ /**
99
+ * Resolve a product's taxonomy into the shape every READ path expects:
100
+ * `categorySlugs` as the FULL ancestor chain (self first, root last), and a
101
+ * brand recorded as both its id and its display name.
102
+ *
103
+ * 🛑 WHY THIS LIVES IN THE REPOSITORY. The seller and admin create/update
104
+ * paths send a SINGLE category id (`category: "category-x-starters"`) — and
105
+ * `productCreateSchema` (src/validation/request-schemas.ts:129) declares only
106
+ * that scalar, with no `.passthrough()`, so an inbound `categorySlugs` array
107
+ * is stripped before it ever reaches Firestore. `mapDoc` then backfills
108
+ * `categorySlugs = [category]` **on read only**, which is invisible to a
109
+ * query: the category page runs `array-contains-any` against the STORED
110
+ * field, so a UI-created listing was unreachable from its own leaf category
111
+ * and from every ancestor of it. CLAUDE.md recorded the ancestor half of this
112
+ * as an outstanding follow-up; the leaf half was never noticed because every
113
+ * product in the database came from the seed, which hand-writes the chain
114
+ * (measured 2026-09-14: 95/95 products carried a correct chain, so the defect
115
+ * is LATENT — it fires the first time anyone creates a listing through the
116
+ * UI, which is precisely why no page looks broken today).
117
+ *
118
+ * There are ~14 independent write paths — two seller actions, two admin
119
+ * actions, four in `_internal/server/features/products/actions.ts`, the
120
+ * duplicate route, group-children, catalogue promotion, shipment linking and
121
+ * the WhatsApp import. Deriving at each one is Root Cause #75's shape: the
122
+ * copy made before a branch was added never learns about it. `create` and
123
+ * `update` are the only two methods every one of them funnels through.
124
+ *
125
+ * 🛑 It reads `categories` through `this.db` rather than importing
126
+ * `categoriesRepository`. No repository in this codebase imports another
127
+ * feature's repository, and the reason is the Turbopack import-chain trap
128
+ * (Root Cause #6/#18) — a raw collection read has neither problem.
129
+ */
130
+ async deriveTaxonomy(input) {
131
+ const out = {};
132
+ const cats = this.db.collection(CATEGORIES_COLLECTION);
133
+ const leaf = (Array.isArray(input.categorySlugs) && input.categorySlugs[0]) ||
134
+ input.category ||
135
+ null;
136
+ /*
137
+ * The brand selector's option value is the brand ROW's id (`brand-beyblade`),
138
+ * but `ProductDocument.brand` is matched by DISPLAY NAME —
139
+ * `BrandDetailPageView` filters `sieveFilter("brand", EQ, brand.name)`, and
140
+ * CLAUDE.md's § "Brand matching is by DISPLAY NAME" says so explicitly. So a
141
+ * seller-created listing stored `brand: "brand-beyblade"` while every seeded
142
+ * one stored `brand: "Beyblade"`, and the brand page matched neither the new
143
+ * row nor counted it. Resolving the id back to its name fixes that; a value
144
+ * that is ALREADY a display name does not resolve as a document id and is
145
+ * left exactly as it was.
146
+ */
147
+ const brandRef = input.brandSlug || input.brand || null;
148
+ const [catSnap, brandSnap] = await Promise.all([
149
+ leaf ? cats.doc(leaf).get() : Promise.resolve(null),
150
+ brandRef ? cats.doc(brandRef).get() : Promise.resolve(null),
151
+ ]);
152
+ if (catSnap?.exists) {
153
+ const cat = catSnap.data();
154
+ // `parentIds` is ordered root-first / nearest-last (category-tree.ts:107),
155
+ // and the stored chain is self-first / root-last. Reversing is what makes
156
+ // the derived value byte-identical to what the seed hand-writes — verified
157
+ // against products-standard-seed-data.ts:197.
158
+ out.categorySlugs = [leaf, ...(cat.parentIds ?? []).slice().reverse()];
159
+ out.category = leaf;
160
+ const names = [
161
+ cat.name,
162
+ ...(cat.ancestors ?? []).map((a) => a?.name).reverse(),
163
+ ].filter((n) => !!n);
164
+ if (names.length)
165
+ out.categoryNames = names;
166
+ }
167
+ else if (leaf) {
168
+ // Never destroy a chain we cannot improve on. A leaf that is not a real
169
+ // category row is a data problem to report, not a reason to blank the field.
170
+ serverLogger.warn("Product category id does not resolve to a category", {
171
+ leaf,
172
+ });
173
+ }
174
+ if (brandSnap?.exists) {
175
+ const brand = brandSnap.data();
176
+ if (brand.categoryType === "brand") {
177
+ out.brandSlug = brandRef;
178
+ if (brand.name)
179
+ out.brand = brand.name;
180
+ }
181
+ }
182
+ return out;
183
+ }
95
184
  async update(id, data) {
96
185
  this.cacheInvalidateForId(id);
97
186
  const current = await super.findById(id);
98
- const merged = { ...current, ...data };
187
+ /*
188
+ * Only re-derive when the write actually names a taxonomy field. Every
189
+ * stock decrement, view increment and status flip would otherwise pay two
190
+ * extra Firestore reads, which Rule #6 budgets at ~3 round-trips for a
191
+ * whole request.
192
+ */
193
+ const touchesTaxonomy = ProductRepository.TAXONOMY_KEYS.some((k) => k in data);
194
+ const derived = touchesTaxonomy
195
+ ? await this.deriveTaxonomy({ ...current, ...data })
196
+ : {};
197
+ const merged = { ...current, ...data, ...derived };
99
198
  const updated = await super.update(id, {
100
199
  ...data,
200
+ ...derived,
101
201
  searchTxt: buildProductSearchTxt(merged),
102
202
  });
103
203
  this.cacheSet(updated);
@@ -117,12 +217,18 @@ export class ProductRepository extends BaseRepository {
117
217
  }
118
218
  });
119
219
  const barcodeId = input.barcodeId ?? await generateBarcodeId(id);
220
+ // Always derive on create — one extra read per listing created, and it is
221
+ // what makes a UI-created product reachable from its own category page at
222
+ // all. A seed row already carrying the correct chain derives to the same
223
+ // value, so this is a no-op for every existing fixture.
224
+ const derived = await this.deriveTaxonomy(input);
225
+ const withTaxonomy = { ...input, ...derived };
120
226
  const productData = {
121
- ...input,
227
+ ...withTaxonomy,
122
228
  barcodeId,
123
229
  slug: id,
124
230
  availableQuantity: input.stockQuantity,
125
- searchTxt: buildProductSearchTxt(input),
231
+ searchTxt: buildProductSearchTxt(withTaxonomy),
126
232
  createdAt: new Date(),
127
233
  updatedAt: new Date(),
128
234
  };
@@ -525,6 +631,13 @@ export class ProductRepository extends BaseRepository {
525
631
  }
526
632
  }
527
633
  ProductRepository.CACHE_TTL_MS = 30000;
634
+ /** The keys whose presence means a write could change the product's taxonomy. */
635
+ ProductRepository.TAXONOMY_KEYS = [
636
+ "categorySlugs",
637
+ "category",
638
+ "brand",
639
+ "brandSlug",
640
+ ];
528
641
  ProductRepository.SIEVE_FIELDS = {
529
642
  id: { canFilter: true, canSort: false },
530
643
  title: { canFilter: true, canSort: true },
@@ -429,9 +429,46 @@ export type ProductUpdateInput = Partial<Pick<ProductDocument, (typeof PRODUCT_U
429
429
  export type ProductAdminUpdateInput = Partial<Omit<ProductDocument, "id" | "createdAt">>;
430
430
  export declare const PRODUCT_CODES_SUBCOLLECTION: "codes";
431
431
  export type ProductCodeStatus = "available" | "claimed" | "revoked";
432
+ /**
433
+ * What the buyer actually receives. A "digital code" listing delivers digital
434
+ * CONTENT — a redemption string is only the commonest shape of it.
435
+ *
436
+ * `"code"` is the default and is what every pre-existing document is, so no
437
+ * migration is needed and no existing listing changes behaviour.
438
+ */
439
+ export type ProductCodeContentKind = "code" | "image" | "file";
432
440
  export interface ProductCodeDocument extends BaseDocument {
433
441
  productId: string;
442
+ /**
443
+ * The redemption string. Required for `contentKind: "code"`, empty for the
444
+ * asset kinds — a QR is not a string.
445
+ */
434
446
  code: string;
447
+ /** Defaults to `"code"` when absent. */
448
+ contentKind?: ProductCodeContentKind;
449
+ /**
450
+ * 🛑 A RAW FIREBASE STORAGE PATH, NEVER a `/media/{slug}` URL, and never a
451
+ * signed URL.
452
+ *
453
+ * Two facts make this load-bearing:
454
+ * - `storage.rules` is `allow read: if true` for every object in the
455
+ * bucket, and
456
+ * - `GET /api/media/[...slug]` applies NO authentication at all — it is a
457
+ * bare exported handler with no session read, by design, because it
458
+ * exists to serve public product photos through a watermarker.
459
+ *
460
+ * So anything reachable at a `/media/` slug is world-readable, and image
461
+ * filenames in this codebase are content-derived and therefore guessable.
462
+ * Putting a paid QR there would publish it. Instead the bytes are streamed by
463
+ * `GET /api/orders/{orderId}/code/asset`, which re-checks that the caller
464
+ * owns the order — the same shape as the invoice route, the one existing
465
+ * precedent for a gated download here.
466
+ */
467
+ assetPath?: string;
468
+ /** Original upload name, used for the `Content-Disposition` filename. */
469
+ fileName?: string;
470
+ /** Verified by magic bytes at upload, replayed as the response Content-Type. */
471
+ contentType?: string;
435
472
  status: ProductCodeStatus;
436
473
  orderId?: string;
437
474
  claimedByUserId?: string;
@@ -37,7 +37,7 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
37
37
  * it can be reasoned about).
38
38
  */
39
39
  findApprovedByProduct(productId: string, limit?: number): Promise<ReviewDocument[]>;
40
- findApprovedByStore(storeId: string): Promise<ReviewDocument[]>;
40
+ findApprovedByStore(storeId: string, limit?: number): Promise<ReviewDocument[]>;
41
41
  findByUser(userId: string): Promise<ReviewDocument[]>;
42
42
  findApprovedByUser(userId: string): Promise<ReviewDocument[]>;
43
43
  findPending(): Promise<ReviewDocument[]>;
@@ -101,13 +101,13 @@ class ReviewRepository extends BaseRepository {
101
101
  const snapshot = await query.get();
102
102
  return snapshot.docs.map((doc) => this.mapDoc(doc));
103
103
  }
104
- async findApprovedByStore(storeId) {
104
+ async findApprovedByStore(storeId, limit = 50) {
105
105
  const snapshot = await this.db
106
106
  .collection(this.collection)
107
107
  .where(REVIEW_FIELDS.STORE_ID, "==", storeId)
108
108
  .where(REVIEW_FIELDS.STATUS, "==", "approved")
109
109
  .orderBy(REVIEW_FIELDS.CREATED_AT, "desc")
110
- .limit(50)
110
+ .limit(limit)
111
111
  .get();
112
112
  return snapshot.docs.map((doc) => this.mapDoc(doc));
113
113
  }
@@ -448,19 +448,10 @@ export async function listSellerOrders(userId, params) {
448
448
  hasMore: false,
449
449
  };
450
450
  }
451
- const sellerProducts = await productRepository.findByStore(store.id);
452
- const productIds = sellerProducts.map((p) => p.id);
453
- if (productIds.length === 0) {
454
- return {
455
- items: [],
456
- total: 0,
457
- page: 1,
458
- pageSize: params?.pageSize ?? 20,
459
- totalPages: 0,
460
- hasMore: false,
461
- };
462
- }
463
- return orderRepository.listForSeller(productIds, {
451
+ // No products query: orders are scoped by `storeId`, the field a cart is
452
+ // split on. Fetching every product to build a `productId in [...]` clause was
453
+ // both an extra round trip and a hard 30-value Firestore cap.
454
+ return orderRepository.listForSeller(store.id, {
464
455
  filters: params?.filters,
465
456
  sorts: params?.sorts ?? "-createdAt",
466
457
  page: params?.page ?? 1,
@@ -94,6 +94,12 @@ export interface SellerProductShellProps {
94
94
  listingType?: ProductListingMode;
95
95
  initialValues?: SellerProductDraft;
96
96
  productId?: string;
97
+ /**
98
+ * The digital-content pool manager, injected by the consumer so appkit never
99
+ * hard-codes an API path. Rendered in EDIT MODE only — a pool is keyed on a
100
+ * product id that does not exist until the listing has been created once.
101
+ */
102
+ renderDigitalContentPool?: (productId: string) => React.ReactNode;
97
103
  onSave: (draft: SellerProductDraft) => Promise<ProductActionResult>;
98
104
  onPublish: (draft: SellerProductDraft) => Promise<ProductActionResult>;
99
105
  onDiscard?: () => void;
@@ -129,4 +135,4 @@ export interface SellerProductShellProps {
129
135
  */
130
136
  previewSlot?: () => React.ReactNode;
131
137
  }
132
- export declare function SellerProductShell({ mode, listingType, initialValues, onSave, onPublish, onDiscard, isLoading, storeSlug, renderCategorySelector, renderBrandSelector, renderAddressSelector, renderTemplateSelector, onSaveAsTemplate, previewSlot, }: SellerProductShellProps): React.JSX.Element;
138
+ export declare function SellerProductShell({ mode, listingType, initialValues, productId, onSave, onPublish, onDiscard, isLoading, storeSlug, renderCategorySelector, renderBrandSelector, renderAddressSelector, renderTemplateSelector, onSaveAsTemplate, previewSlot, renderDigitalContentPool, }: SellerProductShellProps): React.JSX.Element;
@@ -351,7 +351,13 @@ const EDIT_SECTIONS = [
351
351
  { id: "publish", label: "Publish" },
352
352
  ];
353
353
  // ── Main SellerProductShell ───────────────────────────────────────────────
354
- export function SellerProductShell({ mode, listingType = "standard", initialValues, onSave, onPublish, onDiscard, isLoading = false, storeSlug, renderCategorySelector, renderBrandSelector, renderAddressSelector, renderTemplateSelector, onSaveAsTemplate, previewSlot, }) {
354
+ export function SellerProductShell({ mode, listingType = "standard", initialValues,
355
+ /*
356
+ * `productId` was declared on the props interface and NEVER DESTRUCTURED —
357
+ * dead from the day it was added, because nothing in the shell needed it.
358
+ * The digital-content pool is the first thing that does.
359
+ */
360
+ productId, onSave, onPublish, onDiscard, isLoading = false, storeSlug, renderCategorySelector, renderBrandSelector, renderAddressSelector, renderTemplateSelector, onSaveAsTemplate, previewSlot, renderDigitalContentPool, }) {
355
361
  const [draft, setDraft] = useState(initialValues ?? { status: "draft", condition: "new" });
356
362
  const [formMode, setFormMode] = useState(mode === "create" && listingType === "standard" ? "quick" : "full");
357
363
  /*
@@ -583,5 +589,8 @@ export function SellerProductShell({ mode, listingType = "standard", initialValu
583
589
  ...EDIT_SECTIONS,
584
590
  ...(typeSpecificSection ? [{ id: typeSpecificSection.id, label: typeSpecificSection.label }] : []),
585
591
  ];
586
- return (_jsx(FormShell, { isOpen: true, onClose: handleDiscard, title: title, breadcrumb: breadcrumb, isDirty: isDirty, isLoading: isLoading, sections: editSections, onSaveDraft: handleSave, onPublish: handlePublish, saveLabel: "Save Changes", publishLabel: "Update", previewSlot: previewSlot, footerTopSlot: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(FormErrorSummary, {}) }), children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Section, { id: "basic", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Basic Info" }), _jsx(StepBasic, { values: draft, onChange: update, renderCategorySelector: renderCategorySelector, renderBrandSelector: renderBrandSelector, renderTemplateSelector: renderTemplateSelector })] }), _jsxs(Section, { id: "media", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Media" }), _jsx(StepMedia, { values: draft, onChange: update, storeSlug: storeSlug })] }), typeSpecificSection && (_jsxs(Section, { id: typeSpecificSection.id, children: [_jsx(Heading, { level: 3, className: "mb-4", children: typeSpecificSection.sectionHeading ?? typeSpecificSection.label }), typeSpecificSection.render({ values: draft, onChange: update, errors: {} })] })), _jsxs(Section, { id: "pricing", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Pricing" }), _jsx(StepPricing, { values: draft, onChange: update, listingType: listingType })] }), _jsxs(Section, { id: "shipping", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Shipping" }), _jsx(StepShipping, { values: draft, onChange: update, renderAddressSelector: renderAddressSelector })] }), _jsxs(Section, { id: "returns", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Returns" }), _jsx(StepReturns, { values: draft, onChange: update })] }), _jsxs(Section, { id: "publish", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Publish" }), _jsx(StepPublish, { values: draft, onChange: update }), onSaveAsTemplate && (_jsxs(Div, { className: "mt-4 border-t border-[var(--appkit-color-border)]", padding: "t-md", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onSaveAsTemplate(draft), children: "Save as Template" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-secondary-text)]", size: "xs", children: "Save these settings as a reusable template for future listings." })] }))] })] }) }) }));
592
+ return (_jsx(FormShell, { isOpen: true, onClose: handleDiscard, title: title, breadcrumb: breadcrumb, isDirty: isDirty, isLoading: isLoading, sections: editSections, onSaveDraft: handleSave, onPublish: handlePublish, saveLabel: "Save Changes", publishLabel: "Update", previewSlot: previewSlot, footerTopSlot: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(FormErrorSummary, {}) }), children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Section, { id: "basic", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Basic Info" }), _jsx(StepBasic, { values: draft, onChange: update, renderCategorySelector: renderCategorySelector, renderBrandSelector: renderBrandSelector, renderTemplateSelector: renderTemplateSelector })] }), _jsxs(Section, { id: "media", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Media" }), _jsx(StepMedia, { values: draft, onChange: update, storeSlug: storeSlug })] }), typeSpecificSection && (_jsxs(Section, { id: typeSpecificSection.id, children: [_jsx(Heading, { level: 3, className: "mb-4", children: typeSpecificSection.sectionHeading ?? typeSpecificSection.label }), typeSpecificSection.render({ values: draft, onChange: update, errors: {} }), mode === "edit" &&
593
+ listingType === "digital-code" &&
594
+ productId &&
595
+ renderDigitalContentPool?.(productId)] })), _jsxs(Section, { id: "pricing", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Pricing" }), _jsx(StepPricing, { values: draft, onChange: update, listingType: listingType })] }), _jsxs(Section, { id: "shipping", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Shipping" }), _jsx(StepShipping, { values: draft, onChange: update, renderAddressSelector: renderAddressSelector })] }), _jsxs(Section, { id: "returns", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Returns" }), _jsx(StepReturns, { values: draft, onChange: update })] }), _jsxs(Section, { id: "publish", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Publish" }), _jsx(StepPublish, { values: draft, onChange: update }), onSaveAsTemplate && (_jsxs(Div, { className: "mt-4 border-t border-[var(--appkit-color-border)]", padding: "t-md", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onSaveAsTemplate(draft), children: "Save as Template" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-secondary-text)]", size: "xs", children: "Save these settings as a reusable template for future listings." })] }))] })] }) }) }));
587
596
  }
@@ -7,9 +7,14 @@
7
7
  * export { storeReviewsGET as GET } from "@mohasinac/feat-stores";
8
8
  * ```
9
9
  *
10
- * Aggregates approved reviews from up to 20 published products in a store.
10
+ * Aggregates every approved review filed against a store, by `storeId`.
11
11
  * Returns: reviews[], averageRating, totalReviews, ratingDistribution.
12
12
  *
13
+ * It used to aggregate "up to 20 published products by itemsSold" and fan out a
14
+ * review query per product — and since no product carries `itemsSold`, that
15
+ * `orderBy` matched nothing and the endpoint returned zero reviews for every
16
+ * store. See the block comment at the query for the full account.
17
+ *
13
18
  * Requires `db` registered in providers.config via `registerProviders()`.
14
19
  * Collections: "stores", "products", "reviews"
15
20
  */