@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.
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
- package/dist/_internal/server/features/checkout/actions.js +18 -0
- package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
- package/dist/_internal/server/features/digital-code/download.js +128 -0
- package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
- package/dist/_internal/server/features/digital-code/pool.js +208 -0
- package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
- package/dist/_internal/server/features/digital-code/upload.js +144 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
- package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
- package/dist/_internal/server/notifications/send-recorder.js +126 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +6 -0
- package/dist/features/blog/components/BlogIndexPageView.js +10 -1
- package/dist/features/blog/repository/blog.repository.d.ts +14 -0
- package/dist/features/blog/repository/blog.repository.js +23 -1
- package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
- package/dist/features/categories/components/CategoryTree.js +1 -1
- package/dist/features/categories/repository/categories.repository.d.ts +34 -2
- package/dist/features/categories/repository/categories.repository.js +31 -9
- package/dist/features/contact/email.js +36 -1
- package/dist/features/orders/repository/orders.repository.d.ts +21 -1
- package/dist/features/orders/repository/orders.repository.js +26 -9
- package/dist/features/products/repository/products.repository.d.ts +35 -0
- package/dist/features/products/repository/products.repository.js +116 -3
- package/dist/features/products/schemas/firestore.d.ts +37 -0
- package/dist/features/reviews/repository/reviews.repository.d.ts +1 -1
- package/dist/features/reviews/repository/reviews.repository.js +2 -2
- package/dist/features/seller/actions/seller-actions.js +4 -13
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
- package/dist/features/seller/components/SellerProductShell.js +11 -2
- package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
- package/dist/features/stores/api/[storeSlug]/reviews/route.js +134 -38
- package/dist/features/stores/components/StoresIndexPageView.js +17 -1
- package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
- package/dist/features/tester/utils/phases.js +78 -12
- package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
- package/dist/providers/db-firebase/rtdb-paths.js +7 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +12 -0
- package/dist/ui/components/FilePickerButton.d.ts +32 -0
- package/dist/ui/components/FilePickerButton.js +23 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +7 -0
- 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 (
|
|
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,
|
|
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":
|
|
395
|
-
"metrics.totalAuctionCount":
|
|
396
|
-
"metrics.totalItemCount":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
310
|
+
let baseQuery = this.getCollection().where(ORDER_FIELDS.STORE_ID, "==", storeId);
|
|
291
311
|
if (plan.head) {
|
|
292
|
-
// `array-contains` alongside
|
|
293
|
-
// limits are on `array-contains` and on the
|
|
294
|
-
// `array-contains-any` family separately
|
|
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
|
-
|
|
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
|
-
...
|
|
227
|
+
...withTaxonomy,
|
|
122
228
|
barcodeId,
|
|
123
229
|
slug: id,
|
|
124
230
|
availableQuantity: input.stockQuantity,
|
|
125
|
-
searchTxt: buildProductSearchTxt(
|
|
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(
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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,
|
|
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: {} })
|
|
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
|
|
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
|
*/
|