@mohasinac/appkit 4.32.6 → 4.33.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/server/features/products/actions.js +29 -3
- package/dist/_internal/server/features/products/list-public.d.ts +1 -1
- package/dist/_internal/server/features/products/list-public.js +10 -2
- package/dist/_internal/server/features/stores/denormalize.d.ts +44 -0
- package/dist/_internal/server/features/stores/denormalize.js +58 -0
- package/dist/_internal/server/features/stores/index.d.ts +1 -0
- package/dist/_internal/server/features/stores/index.js +1 -0
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/storeNameBackfill.d.ts +27 -0
- package/dist/_internal/server/jobs/core/storeNameBackfill.js +90 -0
- package/dist/_internal/shared/features/products/to-product-item.js +7 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +4 -1
- package/dist/features/about/components/PolicyPageView.js +1 -1
- package/dist/features/admin/actions/admin-actions.js +11 -1
- package/dist/features/admin/components/AdminSectionsView.js +17 -20
- package/dist/features/admin/components/sections/adminSectionsBuildParse.js +31 -14
- package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +37 -7
- package/dist/features/admin/components/sections/adminSectionsTypes.js +11 -7
- package/dist/features/auctions/components/AuctionDetailPageView.js +56 -36
- package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +9 -0
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -2
- package/dist/features/blog/components/BlogFeaturedCard.d.ts +7 -1
- package/dist/features/blog/components/BlogFeaturedCard.js +3 -3
- package/dist/features/cart/repository/cart.repository.d.ts +27 -6
- package/dist/features/cart/repository/cart.repository.js +52 -21
- package/dist/features/cart/utils/guest-cart.js +5 -0
- package/dist/features/cart/utils/pending-ops.d.ts +15 -0
- package/dist/features/cart/utils/pending-ops.js +24 -0
- package/dist/features/homepage/components/BlogArticlesSection.d.ts +7 -1
- package/dist/features/homepage/components/BlogArticlesSection.js +5 -3
- package/dist/features/homepage/components/CTABannerSection.d.ts +18 -1
- package/dist/features/homepage/components/CTABannerSection.js +22 -4
- package/dist/features/homepage/components/CustomerReviewsSection.d.ts +7 -1
- package/dist/features/homepage/components/CustomerReviewsSection.js +2 -2
- package/dist/features/homepage/components/FAQSection.js +2 -11
- package/dist/features/homepage/components/FeaturedAuctionsSection.d.ts +3 -1
- package/dist/features/homepage/components/FeaturedAuctionsSection.js +5 -3
- package/dist/features/homepage/components/FeaturedBundlesSection.d.ts +7 -1
- package/dist/features/homepage/components/FeaturedBundlesSection.js +6 -5
- package/dist/features/homepage/components/FeaturedPreOrdersSection.d.ts +3 -1
- package/dist/features/homepage/components/FeaturedPreOrdersSection.js +4 -2
- package/dist/features/homepage/components/FeaturedProductsSection.d.ts +3 -1
- package/dist/features/homepage/components/FeaturedProductsSection.js +5 -3
- package/dist/features/homepage/components/HeroCarousel.d.ts +23 -2
- package/dist/features/homepage/components/HeroCarousel.js +22 -9
- package/dist/features/homepage/components/HomepageCustomerReviewsSection.d.ts +6 -1
- package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +11 -3
- package/dist/features/homepage/components/MarketplaceHomepageView.d.ts +9 -1
- package/dist/features/homepage/components/MarketplaceHomepageView.js +7 -4
- package/dist/features/homepage/components/StatsCounterSection.d.ts +6 -1
- package/dist/features/homepage/components/StatsCounterSection.js +3 -3
- package/dist/features/homepage/constants/faq-category-labels.d.ts +15 -0
- package/dist/features/homepage/constants/faq-category-labels.js +23 -0
- package/dist/features/homepage/constants/section-copy.d.ts +107 -0
- package/dist/features/homepage/constants/section-copy.js +96 -0
- package/dist/features/homepage/lib/section-renderer.d.ts +16 -1
- package/dist/features/homepage/lib/section-renderer.js +58 -37
- package/dist/features/homepage/repository/homepage-sections.repository.d.ts +23 -0
- package/dist/features/homepage/repository/homepage-sections.repository.js +40 -0
- package/dist/features/homepage/schemas/firestore.d.ts +120 -44
- package/dist/features/media/MediaImage.d.ts +10 -1
- package/dist/features/media/MediaImage.js +6 -3
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
- package/dist/features/products/components/CardStoreLine.d.ts +32 -0
- package/dist/features/products/components/CardStoreLine.js +14 -0
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -1
- package/dist/features/products/components/ProductGrid.js +3 -6
- package/dist/features/products/schemas/firestore.d.ts +17 -0
- package/dist/features/products/types/index.d.ts +2 -0
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +54 -0
- package/dist/features/stores/components/StoreAuctionsListing.js +3 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +137 -0
- package/dist/seed/carousel-slides-seed-data.js +154 -22
- package/dist/seed/carousels-seed-data.js +4 -1
- package/dist/seed/homepage-sections-seed-data.js +337 -254
- package/dist/seed/products-preorders-seed-data.d.ts +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +4 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -6,6 +6,32 @@ import { isAdminUser } from "../../../../features/auth/role-predicates";
|
|
|
6
6
|
import { productInputSchema, productUpdateSchema, auctionInputSchema, preOrderInputSchema, setFeaturedSchema, setStatusSchema } from "../../../shared/features/products/schema";
|
|
7
7
|
import { assertProductOwnership, assertStatusTransition, assertPrizeDrawNotLocked, assertPrizeDrawWonItemsImmutable, } from "./service";
|
|
8
8
|
import { ValidationError } from "../../../shared/errors/index";
|
|
9
|
+
import { NotFoundError } from "../../../../errors";
|
|
10
|
+
import { storeRepository } from "../../../../features/stores/repository/store.repository";
|
|
11
|
+
/**
|
|
12
|
+
* The store fields every listing must carry, resolved from the acting seller.
|
|
13
|
+
*
|
|
14
|
+
* All three create actions used to write `storeId: user.uid` — an Auth UID,
|
|
15
|
+
* where every query in the codebase expects the store SLUG (`storeId` IS the
|
|
16
|
+
* store slug; see the store-identity note in CLAUDE.md). So a listing created
|
|
17
|
+
* here was filed under an id no store page, cart grouping or payout ever
|
|
18
|
+
* matches, and carried no `storeName` for a card to render.
|
|
19
|
+
*
|
|
20
|
+
* `storeName`/`storeSlug` are denormalized here because the public browse query
|
|
21
|
+
* never reads `storeRepository` — a card can only show a seller if the product
|
|
22
|
+
* document already names one.
|
|
23
|
+
*/
|
|
24
|
+
async function sellerStoreFields(ownerId) {
|
|
25
|
+
const store = await storeRepository.findByOwnerId(ownerId);
|
|
26
|
+
if (!store) {
|
|
27
|
+
throw new NotFoundError("No store found for this account. Create your store first.");
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
storeId: store.storeSlug,
|
|
31
|
+
storeName: store.storeName,
|
|
32
|
+
storeSlug: store.storeSlug,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
9
35
|
export async function createProductAction(input) {
|
|
10
36
|
return wrapAction(async () => {
|
|
11
37
|
const user = await requireRoleUser(["seller", "admin"]);
|
|
@@ -14,7 +40,7 @@ export async function createProductAction(input) {
|
|
|
14
40
|
throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
|
|
15
41
|
return productRepository.create({
|
|
16
42
|
...parsed.data,
|
|
17
|
-
|
|
43
|
+
...(await sellerStoreFields(user.uid)),
|
|
18
44
|
status: "draft",
|
|
19
45
|
listingType: "standard",
|
|
20
46
|
featured: false,
|
|
@@ -29,7 +55,7 @@ export async function createAuctionAction(input) {
|
|
|
29
55
|
throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
|
|
30
56
|
return productRepository.create({
|
|
31
57
|
...parsed.data,
|
|
32
|
-
|
|
58
|
+
...(await sellerStoreFields(user.uid)),
|
|
33
59
|
status: "draft",
|
|
34
60
|
listingType: "auction",
|
|
35
61
|
featured: false,
|
|
@@ -44,7 +70,7 @@ export async function createPreOrderAction(input) {
|
|
|
44
70
|
throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
|
|
45
71
|
return productRepository.create({
|
|
46
72
|
...parsed.data,
|
|
47
|
-
|
|
73
|
+
...(await sellerStoreFields(user.uid)),
|
|
48
74
|
status: "draft",
|
|
49
75
|
listingType: "pre-order",
|
|
50
76
|
featured: false,
|
|
@@ -218,5 +218,5 @@ export declare function listPublicProducts(input: PublicProductListInput, opts?:
|
|
|
218
218
|
export declare function listStoreProducts(storeId: string, listingTypes: readonly string[], searchParams?: URLSearchParams | Record<string, string | string[] | undefined>, defaults?: {
|
|
219
219
|
pageSize?: number;
|
|
220
220
|
sorts?: string;
|
|
221
|
-
}): Promise<PublicProductListResult | null>;
|
|
221
|
+
}, opts?: PublicProductListOptions): Promise<PublicProductListResult | null>;
|
|
222
222
|
export {};
|
|
@@ -751,7 +751,15 @@ function finish(draft) {
|
|
|
751
751
|
* something it never had: the URL's own sort/filter/page params reach the SSR
|
|
752
752
|
* fetch, so the first paint matches what the toolbar says is selected.
|
|
753
753
|
*/
|
|
754
|
-
export async function listStoreProducts(storeId, listingTypes, searchParams = {}, defaults
|
|
754
|
+
export async function listStoreProducts(storeId, listingTypes, searchParams = {}, defaults,
|
|
755
|
+
/*
|
|
756
|
+
* Forwarded, because dropping it made three capabilities unreachable from
|
|
757
|
+
* every store tab: the viewer (sandbox visibility), `enabledListingTypes` (a
|
|
758
|
+
* type an admin has switched OFF still rendered its tab's rows), and the
|
|
759
|
+
* pinned `now`. The wrapper accepted none of them, so no caller could pass
|
|
760
|
+
* them however much it wanted to.
|
|
761
|
+
*/
|
|
762
|
+
opts) {
|
|
755
763
|
return listPublicProducts({
|
|
756
764
|
...parsePublicProductParams(searchParams, {
|
|
757
765
|
listingTypes,
|
|
@@ -762,7 +770,7 @@ export async function listStoreProducts(storeId, listingTypes, searchParams = {}
|
|
|
762
770
|
// The route owns the store identity — a `?storeId=` in the URL must not be
|
|
763
771
|
// able to make one store's tab render another store's inventory.
|
|
764
772
|
storeId,
|
|
765
|
-
});
|
|
773
|
+
}, opts);
|
|
766
774
|
}
|
|
767
775
|
/**
|
|
768
776
|
* Comparable primitive for one row value.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a product's denormalized store fields are derived.
|
|
3
|
+
*
|
|
4
|
+
* `ProductDocument.storeName` / `.storeSlug` are what every listing card renders
|
|
5
|
+
* as "by …". They have to be denormalized, because the public browse query
|
|
6
|
+
* (`listPublicProducts`) never touches `storeRepository` — a card gets the name
|
|
7
|
+
* only if the document already carries it.
|
|
8
|
+
*
|
|
9
|
+
* They were optional and effectively never written: the seller wizard, the three
|
|
10
|
+
* `create*Action` server actions and the appkit products route all set `storeId`
|
|
11
|
+
* and stopped there, so only seed data and the admin route ever produced a
|
|
12
|
+
* product with a store name on it. A seeded environment therefore looked correct
|
|
13
|
+
* and production did not — every real seller listing rendered a card with no
|
|
14
|
+
* seller on it.
|
|
15
|
+
*
|
|
16
|
+
* `cart-actions.ts` already carries a private copy of this idea (`resolveStoreName`),
|
|
17
|
+
* written to paper over the same gap one layer downstream. That is deliberately
|
|
18
|
+
* left alone: it resolves at ADD time from whatever the product happens to hold,
|
|
19
|
+
* which is still the correct last line of defence for carts built from products
|
|
20
|
+
* that predate this. This module fixes the source instead.
|
|
21
|
+
*/
|
|
22
|
+
export interface ProductStoreFields {
|
|
23
|
+
storeName: string;
|
|
24
|
+
storeSlug: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the display fields for one store.
|
|
28
|
+
*
|
|
29
|
+
* Degrades rather than throws — a listing that saves without its seller's
|
|
30
|
+
* display name is a cosmetic defect on a card, while a create that 500s because
|
|
31
|
+
* the store lookup blipped is a seller who cannot list. `storeId` IS the store
|
|
32
|
+
* slug in this codebase (see the store identity note in CLAUDE.md), so falling
|
|
33
|
+
* back to it yields a working link and a readable-if-ugly label, never a blank.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveStoreFields(storeId: string, route: string): Promise<ProductStoreFields>;
|
|
36
|
+
/**
|
|
37
|
+
* Batch form, for the backfill job and the rename re-sync.
|
|
38
|
+
*
|
|
39
|
+
* Sequential rather than `Promise.all` on purpose: the caller is a background
|
|
40
|
+
* job over a whole catalogue, distinct stores are few (the grouping is what
|
|
41
|
+
* makes this cheap), and a burst of parallel reads is exactly the shape Rule #6
|
|
42
|
+
* asks not to write.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveStoreFieldsMany(storeIds: Iterable<string>, route: string): Promise<Map<string, ProductStoreFields>>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a product's denormalized store fields are derived.
|
|
3
|
+
*
|
|
4
|
+
* `ProductDocument.storeName` / `.storeSlug` are what every listing card renders
|
|
5
|
+
* as "by …". They have to be denormalized, because the public browse query
|
|
6
|
+
* (`listPublicProducts`) never touches `storeRepository` — a card gets the name
|
|
7
|
+
* only if the document already carries it.
|
|
8
|
+
*
|
|
9
|
+
* They were optional and effectively never written: the seller wizard, the three
|
|
10
|
+
* `create*Action` server actions and the appkit products route all set `storeId`
|
|
11
|
+
* and stopped there, so only seed data and the admin route ever produced a
|
|
12
|
+
* product with a store name on it. A seeded environment therefore looked correct
|
|
13
|
+
* and production did not — every real seller listing rendered a card with no
|
|
14
|
+
* seller on it.
|
|
15
|
+
*
|
|
16
|
+
* `cart-actions.ts` already carries a private copy of this idea (`resolveStoreName`),
|
|
17
|
+
* written to paper over the same gap one layer downstream. That is deliberately
|
|
18
|
+
* left alone: it resolves at ADD time from whatever the product happens to hold,
|
|
19
|
+
* which is still the correct last line of defence for carts built from products
|
|
20
|
+
* that predate this. This module fixes the source instead.
|
|
21
|
+
*/
|
|
22
|
+
import { storeRepository } from "../../../../features/stores/repository/store.repository";
|
|
23
|
+
import { safeRead } from "../../../../errors/safe-read";
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the display fields for one store.
|
|
26
|
+
*
|
|
27
|
+
* Degrades rather than throws — a listing that saves without its seller's
|
|
28
|
+
* display name is a cosmetic defect on a card, while a create that 500s because
|
|
29
|
+
* the store lookup blipped is a seller who cannot list. `storeId` IS the store
|
|
30
|
+
* slug in this codebase (see the store identity note in CLAUDE.md), so falling
|
|
31
|
+
* back to it yields a working link and a readable-if-ugly label, never a blank.
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveStoreFields(storeId, route) {
|
|
34
|
+
const store = await safeRead(() => storeRepository.findById(storeId), {
|
|
35
|
+
route,
|
|
36
|
+
key: "stores.findById",
|
|
37
|
+
fallback: null,
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
storeName: store?.storeName ?? storeId,
|
|
41
|
+
storeSlug: store?.storeSlug ?? storeId,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Batch form, for the backfill job and the rename re-sync.
|
|
46
|
+
*
|
|
47
|
+
* Sequential rather than `Promise.all` on purpose: the caller is a background
|
|
48
|
+
* job over a whole catalogue, distinct stores are few (the grouping is what
|
|
49
|
+
* makes this cheap), and a burst of parallel reads is exactly the shape Rule #6
|
|
50
|
+
* asks not to write.
|
|
51
|
+
*/
|
|
52
|
+
export async function resolveStoreFieldsMany(storeIds, route) {
|
|
53
|
+
const out = new Map();
|
|
54
|
+
for (const storeId of new Set(storeIds)) {
|
|
55
|
+
out.set(storeId, await resolveStoreFields(storeId, route));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { getStoreForDetail, listStoreProductsInitial, listStoreAuctionsInitial, listStorePreOrdersInitial, listSitemapStores, } from "./data";
|
|
2
2
|
export { STORES_PAGE_SIZE, STORES_PRODUCTS_PAGE_SIZE, STORES_SITEMAP_LIMIT, STORES_FEATURED_LIMIT, } from "../../../shared/features/stores/config";
|
|
3
3
|
export { renderStoreOgImage, renderStoreOg, type StoreOgData } from "./og";
|
|
4
|
+
export { resolveStoreFields, resolveStoreFieldsMany, type ProductStoreFields, } from "./denormalize";
|
|
4
5
|
export { toStoreDetail, toStoreListItem, PUBLIC_STORE_FIELDS, PRIVATE_STORE_FIELDS, type ToStoreDetailOptions, type ToStoreListItemOptions, type StoreProjectionSource, } from "./adapters";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { getStoreForDetail, listStoreProductsInitial, listStoreAuctionsInitial, listStorePreOrdersInitial, listSitemapStores, } from "./data";
|
|
2
2
|
export { STORES_PAGE_SIZE, STORES_PRODUCTS_PAGE_SIZE, STORES_SITEMAP_LIMIT, STORES_FEATURED_LIMIT, } from "../../../shared/features/stores/config";
|
|
3
3
|
export { renderStoreOgImage, renderStoreOg } from "./og";
|
|
4
|
+
export { resolveStoreFields, resolveStoreFieldsMany, } from "./denormalize";
|
|
4
5
|
export { toStoreDetail, toStoreListItem, PUBLIC_STORE_FIELDS, PRIVATE_STORE_FIELDS, } from "./adapters";
|
|
@@ -14,6 +14,7 @@ import { runResetOtpVerification } from "./resetOtpVerification";
|
|
|
14
14
|
import { runWhatsAppNotify } from "./whatsappNotify";
|
|
15
15
|
import { runNewsletterExport } from "./newsletterExport";
|
|
16
16
|
import { runWhatsAppCatalogImport } from "./whatsappCatalogImport";
|
|
17
|
+
import { runStoreNameBackfill } from "./storeNameBackfill";
|
|
17
18
|
async function runPayoutsWeeklyJob(_payload, ctx) {
|
|
18
19
|
const { payoutsCreated, ordersProcessed, payoutIds } = await runWeeklyPayoutEligibility(ctx);
|
|
19
20
|
return {
|
|
@@ -49,6 +50,9 @@ async function runNewsletterExportJob(_payload, ctx) {
|
|
|
49
50
|
async function runWhatsAppCatalogImportJob(payload, ctx) {
|
|
50
51
|
return runWhatsAppCatalogImport({ storeSlug: String(payload.storeSlug ?? "") }, ctx);
|
|
51
52
|
}
|
|
53
|
+
async function runStoreNameBackfillJob(payload, ctx) {
|
|
54
|
+
return runStoreNameBackfill({ storeId: String(payload.storeId ?? "") }, ctx);
|
|
55
|
+
}
|
|
52
56
|
export const JOB_RUNNERS = {
|
|
53
57
|
payoutsWeekly: runPayoutsWeeklyJob,
|
|
54
58
|
hardBanCascade: runHardBanCascadeJob,
|
|
@@ -56,4 +60,5 @@ export const JOB_RUNNERS = {
|
|
|
56
60
|
whatsappNotify: runWhatsAppNotifyJob,
|
|
57
61
|
newsletterExport: runNewsletterExportJob,
|
|
58
62
|
whatsappCatalogImport: runWhatsAppCatalogImportJob,
|
|
63
|
+
storeNameBackfill: runStoreNameBackfillJob,
|
|
59
64
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storeNameBackfill — re-sync a store's denormalized display fields onto its
|
|
3
|
+
* products.
|
|
4
|
+
*
|
|
5
|
+
* `ProductDocument.storeName` / `.storeSlug` are denormalized because the public
|
|
6
|
+
* browse query never touches `storeRepository`: a listing card can only show a
|
|
7
|
+
* seller if the product document already names one. Denormalizing without a
|
|
8
|
+
* re-sync is the mirror-drift trap (Root Cause #42) — a renamed store would keep
|
|
9
|
+
* showing its old name on every card it owns, forever, with nothing reporting a
|
|
10
|
+
* problem.
|
|
11
|
+
*
|
|
12
|
+
* This runs as a JOB rather than inline in the rename request because the write
|
|
13
|
+
* is unbounded: a store with hundreds of listings would blow the 10s Vercel
|
|
14
|
+
* ceiling (Rule #6). It reuses the existing Async Job Primitive
|
|
15
|
+
* (`enqueueJob` → `onJobCreated` → JOB_RUNNERS), so it costs no new Cloud
|
|
16
|
+
* Scheduler job.
|
|
17
|
+
*
|
|
18
|
+
* Idempotent: a product already carrying both correct values is skipped, so a
|
|
19
|
+
* re-run over a synced catalogue performs zero writes.
|
|
20
|
+
*/
|
|
21
|
+
import type { JobContext } from "../runtime/types";
|
|
22
|
+
import type { JobRunResult } from "./jobRunners";
|
|
23
|
+
export interface StoreNameBackfillPayload {
|
|
24
|
+
/** The store's slug — which is also its document id, and the products' `storeId`. */
|
|
25
|
+
storeId: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function runStoreNameBackfill(payload: StoreNameBackfillPayload, ctx: JobContext): Promise<JobRunResult>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storeNameBackfill — re-sync a store's denormalized display fields onto its
|
|
3
|
+
* products.
|
|
4
|
+
*
|
|
5
|
+
* `ProductDocument.storeName` / `.storeSlug` are denormalized because the public
|
|
6
|
+
* browse query never touches `storeRepository`: a listing card can only show a
|
|
7
|
+
* seller if the product document already names one. Denormalizing without a
|
|
8
|
+
* re-sync is the mirror-drift trap (Root Cause #42) — a renamed store would keep
|
|
9
|
+
* showing its old name on every card it owns, forever, with nothing reporting a
|
|
10
|
+
* problem.
|
|
11
|
+
*
|
|
12
|
+
* This runs as a JOB rather than inline in the rename request because the write
|
|
13
|
+
* is unbounded: a store with hundreds of listings would blow the 10s Vercel
|
|
14
|
+
* ceiling (Rule #6). It reuses the existing Async Job Primitive
|
|
15
|
+
* (`enqueueJob` → `onJobCreated` → JOB_RUNNERS), so it costs no new Cloud
|
|
16
|
+
* Scheduler job.
|
|
17
|
+
*
|
|
18
|
+
* Idempotent: a product already carrying both correct values is skipped, so a
|
|
19
|
+
* re-run over a synced catalogue performs zero writes.
|
|
20
|
+
*/
|
|
21
|
+
import { storeRepository } from "../../../../features/stores/repository/store.repository";
|
|
22
|
+
import { productRepository } from "../../../../features/products/repository/products.repository";
|
|
23
|
+
import { normalizeError } from "../../../../errors/normalize";
|
|
24
|
+
/** Firestore caps a batched write at 500 operations. */
|
|
25
|
+
const BATCH_LIMIT = 500;
|
|
26
|
+
export async function runStoreNameBackfill(payload, ctx) {
|
|
27
|
+
const { storeId } = payload;
|
|
28
|
+
const empty = {
|
|
29
|
+
summary: { total: 0, succeeded: 0, skipped: 0, failed: 0 },
|
|
30
|
+
succeeded: [],
|
|
31
|
+
skipped: [],
|
|
32
|
+
failed: [],
|
|
33
|
+
};
|
|
34
|
+
if (!storeId) {
|
|
35
|
+
return { ...empty, failed: [{ id: "-", reason: "storeId is required" }],
|
|
36
|
+
summary: { total: 0, succeeded: 0, skipped: 0, failed: 1 } };
|
|
37
|
+
}
|
|
38
|
+
const store = await storeRepository.findById(storeId);
|
|
39
|
+
if (!store) {
|
|
40
|
+
// Not an error worth failing the job over — a store deleted between the
|
|
41
|
+
// rename and this run is a race, not a fault. Its products are handled by
|
|
42
|
+
// the store-deletion cascade, not here.
|
|
43
|
+
ctx.logger.warn("storeNameBackfill: store not found", { storeId });
|
|
44
|
+
return { ...empty, skipped: [storeId], summary: { total: 0, succeeded: 0, skipped: 1, failed: 0 } };
|
|
45
|
+
}
|
|
46
|
+
const products = await productRepository.findByStore(storeId);
|
|
47
|
+
const stale = products.filter((p) => p.storeName !== store.storeName || p.storeSlug !== store.storeSlug);
|
|
48
|
+
const succeeded = [];
|
|
49
|
+
const failed = [];
|
|
50
|
+
for (let i = 0; i < stale.length; i += BATCH_LIMIT) {
|
|
51
|
+
const chunk = stale.slice(i, i + BATCH_LIMIT);
|
|
52
|
+
const batch = ctx.db.batch();
|
|
53
|
+
for (const product of chunk) {
|
|
54
|
+
batch.update(ctx.db.collection("products").doc(product.id), {
|
|
55
|
+
storeName: store.storeName,
|
|
56
|
+
storeSlug: store.storeSlug,
|
|
57
|
+
updatedAt: ctx.now,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await batch.commit();
|
|
62
|
+
succeeded.push(...chunk.map((p) => p.id));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// Per-chunk, not per-job: one failed batch must not discard the chunks
|
|
66
|
+
// that already committed, and the job is idempotent so a retry is safe.
|
|
67
|
+
const reason = normalizeError(err).message;
|
|
68
|
+
failed.push(...chunk.map((p) => ({ id: p.id, reason })));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const skipped = products.length - stale.length;
|
|
72
|
+
ctx.logger.info("storeNameBackfill: complete", {
|
|
73
|
+
storeId,
|
|
74
|
+
storeName: store.storeName,
|
|
75
|
+
updated: succeeded.length,
|
|
76
|
+
skipped,
|
|
77
|
+
failed: failed.length,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
summary: {
|
|
81
|
+
total: products.length,
|
|
82
|
+
succeeded: succeeded.length,
|
|
83
|
+
skipped,
|
|
84
|
+
failed: failed.length,
|
|
85
|
+
},
|
|
86
|
+
succeeded,
|
|
87
|
+
skipped: [],
|
|
88
|
+
failed,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -34,6 +34,13 @@ export function toProductItem(doc) {
|
|
|
34
34
|
status: doc.status ?? "published",
|
|
35
35
|
slug: typeof doc.slug === "string" ? doc.slug : undefined,
|
|
36
36
|
storeName: typeof doc.storeName === "string" ? doc.storeName : undefined,
|
|
37
|
+
// `storeName` alone was carried here, and `storeId` was dropped — the same
|
|
38
|
+
// shape as the `listingType` bug two fields down, one field over. A card
|
|
39
|
+
// needs the id to decide whether it has a seller at all (a name can be
|
|
40
|
+
// absent on a document written before the create paths denormalized it),
|
|
41
|
+
// and the slug to name one without a second read.
|
|
42
|
+
storeId: typeof doc.storeId === "string" ? doc.storeId : undefined,
|
|
43
|
+
storeSlug: typeof doc.storeSlug === "string" ? doc.storeSlug : undefined,
|
|
37
44
|
rating: typeof doc.rating === "number" ? doc.rating : undefined,
|
|
38
45
|
reviewCount: typeof doc.reviewCount === "number" ? doc.reviewCount : undefined,
|
|
39
46
|
// Without this, every related-item card silently linked to the standard
|
package/dist/client.d.ts
CHANGED
|
@@ -260,7 +260,7 @@ export { StoreAddonsPicker, hasAnyStoreAddon } from "./features/cart/index";
|
|
|
260
260
|
export { clientLineTotal } from "./features/cart/utils/line-total";
|
|
261
261
|
export type { PricedCartLine } from "./features/cart/utils/line-total";
|
|
262
262
|
export type { CartPriceBreakdownData, CartPriceBreakdownStore, StoreAddonsValue, StoreAddonsRates } from "./features/cart/index";
|
|
263
|
-
export { getCartOps, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
|
|
263
|
+
export { getCartOps, clearCartOps, removeCartOpsFor, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
|
|
264
264
|
export type { CartOp } from "./features/cart/utils/pending-ops";
|
|
265
265
|
export type { CartItem, CartItemMeta, CartData, GuestCartItem } from "./features/cart/index";
|
|
266
266
|
export type { CartLineKind, CartGroupSource, CartLineMember, } from "./features/cart/schemas/firestore";
|
package/dist/client.js
CHANGED
|
@@ -302,7 +302,10 @@ export { useLogout, useLogin, useGoogleLogin, useLinkGoogleAccount, useRegister,
|
|
|
302
302
|
export { CartView, CartItemRow, CartGroupLineRow, CartSummary, CartPriceBreakdown, CartDrawer, CheckoutView, CheckoutSuccessView, CheckoutAddressStep, useGuestCart, useCartCount, useAddToCart, useCart, useGuestCartMerge, useCartQuery } from "./features/cart/index";
|
|
303
303
|
export { StoreAddonsPicker, hasAnyStoreAddon } from "./features/cart/index";
|
|
304
304
|
export { clientLineTotal } from "./features/cart/utils/line-total";
|
|
305
|
-
|
|
305
|
+
// `clearCartOps` / `removeCartOpsFor` are exported because the cart page has to
|
|
306
|
+
// drain the queue after a removal succeeds server-side — without them a cleared
|
|
307
|
+
// row is re-layered onto the next render and re-POSTed by useSyncManager 30s later.
|
|
308
|
+
export { getCartOps, clearCartOps, removeCartOpsFor, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
|
|
306
309
|
export { useAddresses, useCreateAddress, useUpdateAddress, useDeleteAddress, useSetDefaultAddress, useAddress } from "./features/account/index";
|
|
307
310
|
export { AddressBook, AddressCard, AddressForm } from "./features/account/index";
|
|
308
311
|
export { useProfile, useUpdateProfile } from "./features/account/index";
|
|
@@ -93,5 +93,5 @@ export async function PolicyPageView({ policy, }) {
|
|
|
93
93
|
}));
|
|
94
94
|
return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [_jsx(Section, { color: "inverse", tone: "accent-banner", padding: "banner", children: _jsxs(Div, { className: `${page.container.sm}`, children: [_jsx(Heading, { color: "inverse", level: 1, variant: "none", className: "mb-3", children: t("title") }), _jsx(Text, { color: "inverse", variant: "none", className: "/80", size: "sm", children: t("lastUpdated") })] }) }), _jsxs(Div, { className: `${page.container.sm}`, padding: "content-banner", children: [adminHtml ? (
|
|
95
95
|
/* Admin-set HTML takes priority */
|
|
96
|
-
_jsx(Div, { className: "prose prose-neutral dark:prose-invert max-w-none", dangerouslySetInnerHTML: { __html: adminHtml } })) : (_jsxs(_Fragment, { children: [t("intro") && (_jsx(Text, { variant: "secondary", className: "mb-10 leading-relaxed", size: "base", children: t("intro") })), _jsx(Stack, { gap: "xl", children: sections.map((section, i) => (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "mb-3", children: section.heading }), _jsx(Text, { variant: "secondary", className: "leading-relaxed whitespace-pre-line", children: section.body })] }, i))) })] })), relatedLinks.length > 0 && (_jsxs(Div, { className: `mt-14 border-t`, border: "default", padding: "t-xl", children: [_jsx(Heading, { level: 3, className: "mb-3 uppercase tracking-wide", color: "muted", size: "sm", weight: "semibold", children: t("relatedTitle") }), _jsx(Row, { wrap: true, gap: "md", children: relatedLinks.map((l) => (_jsx(TextLink, { href: l.href, variant: "muted", size: "sm", children: l.label }, l.href))) })] }))] })] }));
|
|
96
|
+
_jsx(Div, { className: "prose prose-neutral dark:prose-invert max-w-none", dangerouslySetInnerHTML: { __html: adminHtml } })) : (_jsxs(_Fragment, { children: [t.has("intro") && (_jsx(Text, { variant: "secondary", className: "mb-10 leading-relaxed", size: "base", children: t("intro") })), _jsx(Stack, { gap: "xl", children: sections.map((section, i) => (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "mb-3", children: section.heading }), _jsx(Text, { variant: "secondary", className: "leading-relaxed whitespace-pre-line", children: section.body })] }, i))) })] })), relatedLinks.length > 0 && (_jsxs(Div, { className: `mt-14 border-t`, border: "default", padding: "t-xl", children: [_jsx(Heading, { level: 3, className: "mb-3 uppercase tracking-wide", color: "muted", size: "sm", weight: "semibold", children: t("relatedTitle") }), _jsx(Row, { wrap: true, gap: "md", children: relatedLinks.map((l) => (_jsx(TextLink, { href: l.href, variant: "muted", size: "sm", children: l.label }, l.href))) })] }))] })] }));
|
|
97
97
|
}
|
|
@@ -17,8 +17,10 @@ import { getProviders } from "../../../contracts";
|
|
|
17
17
|
import { getAdminAuth } from "../../../providers/db-firebase";
|
|
18
18
|
import { isAdminUser } from "../../auth/role-predicates";
|
|
19
19
|
import { assertPrizeDrawNotLocked, assertPrizeDrawWonItemsImmutable, } from "../../../_internal/server/features/products/service";
|
|
20
|
+
import { resolveStoreFields } from "../../../_internal/server/features/stores/denormalize";
|
|
20
21
|
import { recordAdminAction } from "../../../_internal/server/features/audit-log/actions";
|
|
21
22
|
import { AdminAuditActionValues } from "../../audit-log/schemas/firestore";
|
|
23
|
+
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
22
24
|
const ERR_UID_REQUIRED = "uid is required";
|
|
23
25
|
export async function revokeSession(adminId, sessionId) {
|
|
24
26
|
if (!sessionId?.trim()) {
|
|
@@ -253,10 +255,18 @@ export async function adminUpdateProduct(adminId, id, input) {
|
|
|
253
255
|
return updated;
|
|
254
256
|
}
|
|
255
257
|
export async function adminCreateProduct(admin, input) {
|
|
258
|
+
// Resolve the real store rather than falling back to the literal "Admin",
|
|
259
|
+
// which is not a seller and renders as one on every listing card. `storeSlug`
|
|
260
|
+
// is denormalized alongside so a card can link without a second read; both
|
|
261
|
+
// re-sync through the storeNameBackfill job when the store is renamed.
|
|
262
|
+
const storeFields = input.storeId
|
|
263
|
+
? await resolveStoreFields(input.storeId, ADMIN_ENDPOINTS.PRODUCTS)
|
|
264
|
+
: null;
|
|
256
265
|
const product = await productRepository.create({
|
|
257
266
|
...input,
|
|
258
267
|
storeId: input.storeId,
|
|
259
|
-
storeName: input.storeName ||
|
|
268
|
+
storeName: input.storeName || storeFields?.storeName,
|
|
269
|
+
storeSlug: storeFields?.storeSlug,
|
|
260
270
|
});
|
|
261
271
|
serverLogger.info("adminCreateProduct", {
|
|
262
272
|
adminId: admin.uid,
|