Files
cms/src/utils/createComponentLifecycle.ts
Michael Czechowski 32a296baf2
Some checks failed
Build and publish / build (push) Failing after 17s
feat: extract cms from mp/cms — initial libreshop/cms
Source moved verbatim from mp/cms/ on 2026-04-29; mp was the first
concrete adapter consuming the libreshop toolkit. Builds and publishes
git.librete.ch/libreshop/cms on every main / v* push via the standard
.gitea/workflows/build.yml shared across libreshop components.
2026-04-29 17:48:30 +02:00

274 lines
9.5 KiB
TypeScript

import { Strapi } from "@strapi/strapi";
import { productDefaultParams } from "../api/product/services/product";
import { ProductCover, ProductImage, ProductPages, ProductPattern, ProductRuling } from "../../types";
import { formatSlug } from "./formatSlug";
import { ID } from "@strapi/database/dist/types";
type ComponentEvent<T> = {
action: "create" | "update" | "delete";
params: {
data?: T;
where?: { id: number };
select?: string[];
populate?: string[];
};
state?: {
attribute?: string;
};
result?: T;
};
export const createComponentLifecycle = <T extends ProductCover | ProductPattern | ProductPages | ProductRuling | ProductImage>(
componentType: "cover" | "pattern" | "pages" | "ruling" | "image"
) => ({
async afterCreate(event: ComponentEvent<T>) {
const { result } = event;
strapi.log.verbose(`app:v:${componentType}-lifecycle: Created ${JSON.stringify({ componentType, id: result.id })}`);
if (componentType === "image") {
return;
}
try {
await generateNewProducts(strapi, componentType, parseInt(result.id as string));
} catch (error) {
strapi.log.error(`app:e:${componentType}-lifecycle: Error generating products ${JSON.stringify({ error, componentId: result.id })}`);
}
},
async afterUpdate(event: ComponentEvent<T>) {
const { result, params } = event;
// Only update products if price changed
try {
await updateRelatedProducts(strapi, componentType, parseInt(result.id as string));
} catch (error) {
strapi.log.error(
`app:e:${componentType}-lifecycle: Error updating products
${JSON.stringify({
error,
componentId: result.id
})}`
);
}
},
async beforeDelete(event: ComponentEvent<T>) {
const { id } = event.params.where;
try {
// Find all products using this component
strapi.log.debug(
`app:d:${componentType}-lifecycle: Deleting related products`,
JSON.stringify({
filters: {
[componentType]: { id }
}
})
);
const products = await strapi.entityService.findMany("api::product.product", {
filters: {
[componentType]: { id }
}
});
// Delete related products
// TODO: Deleting too many products
if (products.length) {
for (const product of products) {
await strapi.entityService.delete("api::product.product", product.id);
}
}
} catch (error) {
strapi.log.error(
`app:e:${componentType}-lifecycle: Error deleting related products`,
JSON.stringify({
error,
componentId: id
})
);
throw error; // Propagate error to prevent component deletion if products can't be deleted
}
}
});
async function generateNewProducts(strapi: Strapi, componentType: string, componentId: number) {
// Fetch all necessary components
const [covers, patterns, pages, rulings] = await Promise.all([
componentType === "cover"
? [await strapi.entityService.findOne("api::product-cover.product-cover", componentId)]
: strapi.entityService.findMany("api::product-cover.product-cover"),
componentType === "pattern"
? [await strapi.entityService.findOne("api::product-pattern.product-pattern", componentId)]
: strapi.entityService.findMany("api::product-pattern.product-pattern"),
componentType === "pages"
? [await strapi.entityService.findOne("api::product-page.product-page", componentId)]
: strapi.entityService.findMany("api::product-page.product-page"),
componentType === "ruling"
? [await strapi.entityService.findOne("api::product-ruling.product-ruling", componentId)]
: strapi.entityService.findMany("api::product-ruling.product-ruling")
]);
strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`);
// Generate new combinations
for (const cover of covers) {
for (const pattern of patterns) {
for (const page of pages) {
for (const ruling of rulings) {
const name = `${pattern.name} ${cover.name} · ${page.name} · ${ruling.name}`;
const slug = formatSlug(name);
const existing = await strapi.entityService.findMany("api::product.product", {
filters: { slug }
});
if (existing.length) {
strapi.log.warn(`app:w:lifecycle-factory: Product "${name}" already exists with ID ${existing[0]?.id}`);
strapi.log.debug(`app:d:lifecycle-factory: ${JSON.stringify({ existing })}`);
continue;
}
const categoriesFilter = {
filters: {
product_cover: cover.id,
product_pattern: pattern.id
}
};
const categories = await strapi.service("api::product-category.product-category").categories(categoriesFilter);
if (categories) {
strapi.log.debug(`app:d:lifecycle-factory: Attaching product-image with ID ${categories[0]?.id}`);
} else {
strapi.log.warn("app:w:lifecycle-factory: Product-image not found");
}
const images = categories ? categories[0]?.id : null;
const data = {
name,
slug,
images,
cover: cover.id,
pattern: pattern.id,
pages: page.id,
ruling: ruling.id,
// TODO: Might be strapi v5 syntax
// https://docs.strapi.io/cms/migration/v4-to-v5/breaking-changes/publication-state-removed
status: "draft",
publishedAt: null
};
await strapi.entityService.create("api::product.product", {
data
});
}
}
}
}
}
async function updateRelatedProducts(strapi: Strapi, componentType: string, componentId: number) {
// Find all related products with full population
const products = await strapi.entityService.findMany("api::product.product", {
filters: {
[componentType === "image" ? "images" : componentType]: { id: componentId }
},
...productDefaultParams,
publicationState: "preview"
});
// @ts-expect-error
const parent = await strapi.entityService.findOne(`api::product-${componentType}.product-${componentType}`, componentId);
strapi.log.info(`app:i:lifecycle-factory: Updating ${products.length} products related to ${componentType} with ID ${componentId}`);
for (const product of products) {
try {
// Get the current components for this product
const cover = product.cover?.id;
const pattern = product.pattern?.id;
const pages = product.pages?.id;
const ruling = product.ruling?.id;
// Determine if this product should still exist based on component availability
const componentsExist = await validateComponents(strapi, cover, pattern, pages, ruling);
if (!componentsExist) {
// Delete products with missing components
strapi.log.info(`app:i:lifecycle-factory: Deleting product ${product.id} due to missing components`);
await strapi.entityService.delete("api::product.product", product.id);
continue;
}
// Regenerate product name and slug based on current components
const [coverData, patternData, pagesData, rulingData] = await Promise.all([
strapi.entityService.findOne("api::product-cover.product-cover", cover),
strapi.entityService.findOne("api::product-pattern.product-pattern", pattern),
strapi.entityService.findOne("api::product-page.product-page", pages),
strapi.entityService.findOne("api::product-ruling.product-ruling", ruling)
]);
const name = `${patternData.name} ${coverData.name} · ${pagesData.name} · ${rulingData.name}`;
const slug = formatSlug(name);
// Find the correct category/product-image
const categoriesFilter = {
filters: {
product_cover: cover,
product_pattern: pattern
}
};
const categories = await strapi.service("api::product-category.product-category").categories(categoriesFilter);
const images = categories?.length ? categories[0]?.id : null;
// Check if a product with this name already exists (but is not this one)
const existing = await strapi.entityService.findMany("api::product.product", {
filters: {
slug,
id: { $ne: product.id }
}
});
if (existing.length) {
// Duplicate found - delete this one if it's newer
if (new Date(product.createdAt) > new Date(existing[0].createdAt)) {
strapi.log.warn(`app:w:lifecycle-factory: Deleting duplicate product ${product.id} in favor of existing ${existing[0].id}`);
await strapi.entityService.delete("api::product.product", product.id);
}
continue;
}
// Update the product with refreshed data
const updateData = {
name,
slug,
images
// Don't update the publishedAt status here, as it's handled by updateRelatedProductsStatus
};
strapi.log.verbose(`app:v:lifecycle-factory: Updating product ${product.id} with ${JSON.stringify(updateData)}`);
await strapi.entityService.update("api::product.product", product.id, {
data: updateData
});
} catch (error) {
strapi.log.error(`app:e:lifecycle-factory: Error updating product ${product.id}`, error);
}
}
strapi.log.info(`app:i:lifecycle-factory: ✔ Finished updating products related to ${componentType} ${componentId}`);
}
// Helper function to validate that all components of a product exist
async function validateComponents(strapi: Strapi, coverId: ID, patternId: ID, pagesId: ID, rulingId: ID): Promise<boolean> {
try {
const [cover, pattern, pages, ruling] = await Promise.all([
coverId ? strapi.entityService.findOne("api::product-cover.product-cover", coverId) : null,
patternId ? strapi.entityService.findOne("api::product-pattern.product-pattern", patternId) : null,
pagesId ? strapi.entityService.findOne("api::product-page.product-page", pagesId) : null,
rulingId ? strapi.entityService.findOne("api::product-ruling.product-ruling", rulingId) : null
]);
return !!(cover && pattern && pages && ruling);
} catch (error) {
strapi.log.error(`app:e:lifecycle-factory: Error validating components`, error);
return false;
}
}