From 901a3b385f4ca9c05fac7d9d9c1fb2148ad8d4b7 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 23 Sep 2026 22:10:06 +0200 Subject: [PATCH] feat(product): price standalone products from the cover alone Recovered work in progress from 2026-05. A product-cover can be marked standalone, and a standalone product (Journaling Booklet) prices as the cover price without the pattern/pages/ruling lookups, which do not exist for it. The remaining component lookups are now guarded so a missing relation yields null instead of calling findOne with undefined. --- .../content-types/product-cover/schema.json | 4 ++++ .../content-types/product/lifecycles.ts | 24 +++++++++++++++---- src/api/product/controllers/product.ts | 19 ++++++++++++++- src/utils/createComponentLifecycle.ts | 12 +++++++++- types/generated/contentTypes.d.ts | 1 + 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/api/product-cover/content-types/product-cover/schema.json b/src/api/product-cover/content-types/product-cover/schema.json index fc6afd2..9da2c9d 100644 --- a/src/api/product-cover/content-types/product-cover/schema.json +++ b/src/api/product-cover/content-types/product-cover/schema.json @@ -54,6 +54,10 @@ "price": { "type": "decimal" }, + "standalone": { + "type": "boolean", + "default": false + }, "products": { "type": "relation", "relation": "oneToMany", diff --git a/src/api/product/content-types/product/lifecycles.ts b/src/api/product/content-types/product/lifecycles.ts index ffcfabd..f5ef06c 100644 --- a/src/api/product/content-types/product/lifecycles.ts +++ b/src/api/product/content-types/product/lifecycles.ts @@ -11,10 +11,26 @@ export default { strapi.log.verbose("app:v:product-lifecycle: Before create", { params }); - const cover = await strapi.entityService.findOne("api::product-cover.product-cover", params.data?.cover, { fields: ["price"] }); - const pattern = await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data?.pattern); - const pages = await strapi.entityService.findOne("api::product-page.product-page", params.data?.pages, { fields: ["price"] }); - const ruling = await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data?.ruling, { fields: ["price"] }); + const cover = params.data?.cover + ? await strapi.entityService.findOne("api::product-cover.product-cover", params.data.cover, { fields: ["price", "standalone"] }) + : null; + + // Standalone products (e.g. Journaling Booklet) have no pattern/pages/ruling. + // Price is the cover price alone — skip combinatorial component lookups. + if (params.data?.standalone || (cover as any)?.standalone) { + params.data.totalPrice = cover?.price ?? 0; + return; + } + + const pattern = params.data?.pattern + ? await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data.pattern) + : null; + const pages = params.data?.pages + ? await strapi.entityService.findOne("api::product-page.product-page", params.data.pages, { fields: ["price"] }) + : null; + const ruling = params.data?.ruling + ? await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data.ruling, { fields: ["price"] }) + : null; const product = { ...params.data, cover, diff --git a/src/api/product/controllers/product.ts b/src/api/product/controllers/product.ts index 3e95385..9c87936 100644 --- a/src/api/product/controllers/product.ts +++ b/src/api/product/controllers/product.ts @@ -108,6 +108,12 @@ export default factories.createCoreController("api::product.product", ({ strapi strapi.log.verbose(`Fetching variants for product ${id}`); const product = await getProductDetails(id as ID); + + // Standalone products (e.g. Journaling Booklet) have no pattern — no variants. + if (!product.pattern?.id) { + return []; + } + const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", { ...productVariantParams, filters: { @@ -146,6 +152,11 @@ export default factories.createCoreController("api::product.product", ({ strapi const product = await getProductDetails(id as ID); const { pattern, cover, ruling, pages } = product; + // Standalone products (e.g. Journaling Booklet) have no pattern → no variants. + if (!pattern?.id) { + return { allProductPattern: [], productVariants: [], patterns: [] }; + } + const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", { ...productVariantParams, filters: { @@ -203,6 +214,11 @@ export default factories.createCoreController("api::product.product", ({ strapi const product = await getProductDetails(id as ID); + // Standalone products (e.g. Journaling Booklet) have no pattern → no variant groups. + if (!product.pattern?.id) { + return { pages: [], cover: [], ruling: [] }; + } + const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>( "api::product.product", { @@ -335,7 +351,8 @@ export default factories.createCoreController("api::product.product", ({ strapi const productGroups = new Map(); allProductsSanitized.forEach((product) => { - const key = `${product.cover.id}-${product.pattern.id}`; + // Standalone products (e.g. Journaling Booklet) have no pattern — never grouped. + const key = product.pattern?.id ? `${product.cover?.id}-${product.pattern.id}` : `standalone-${product.id}`; if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) { productGroups.set(key, product); } diff --git a/src/utils/createComponentLifecycle.ts b/src/utils/createComponentLifecycle.ts index c8fb329..ab7d841 100644 --- a/src/utils/createComponentLifecycle.ts +++ b/src/utils/createComponentLifecycle.ts @@ -110,8 +110,12 @@ async function generateNewProducts(strapi: Strapi, componentType: string, compon strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`); + // Standalone covers (e.g. the Journaling Booklet) are not combinatorial — + // their product is created manually and must never be fanned out. + const sellableCovers = covers.filter((cover) => cover && !(cover as ProductCover).standalone); + // Generate new combinations - for (const cover of covers) { + for (const cover of sellableCovers) { for (const pattern of patterns) { for (const page of pages) { for (const ruling of rulings) { @@ -181,6 +185,12 @@ async function updateRelatedProducts(strapi: Strapi, componentType: string, comp for (const product of products) { try { + // Standalone products (cover-only, e.g. Journaling Booklet) are not + // factory-managed — never rename/reslug/delete them. + if (!product.pattern?.id) { + continue; + } + // Get the current components for this product const cover = product.cover?.id; const pattern = product.pattern?.id; diff --git a/types/generated/contentTypes.d.ts b/types/generated/contentTypes.d.ts index 43704eb..fbd56db 100644 --- a/types/generated/contentTypes.d.ts +++ b/types/generated/contentTypes.d.ts @@ -453,6 +453,7 @@ export interface ApiProductCoverProductCover extends Schema.CollectionType { number > & Attribute.DefaultTo<0>; + standalone: Attribute.Boolean & Attribute.DefaultTo; updatedAt: Attribute.DateTime; updatedBy: Attribute.Relation<"api::product-cover.product-cover", "oneToOne", "admin::user"> & Attribute.Private; };