feat: extract cms from mp/cms — initial libreshop/cms
Some checks failed
Build and publish / build (push) Failing after 17s

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.
This commit is contained in:
Michael Czechowski
2026-04-29 17:48:30 +02:00
commit 32a296baf2
127 changed files with 44618 additions and 0 deletions

View File

@@ -0,0 +1,442 @@
import { factories } from "@strapi/strapi";
import { sanitize } from "@strapi/utils";
import { Result, PaginatedResult } from "@strapi/types/dist/modules/entity-service/result";
import { ID } from "@strapi/database/dist/types";
import { Product, ProductPattern } from "../../../../types";
import { productDefaultParams, calculateTotalProductPrice } from "../services/product";
import { maxProductsSitemap } from "../../../../config/constants";
export interface ProductVariantParams {
populate: {
pattern: any;
cover: any;
ruling: any;
pages: any;
images: any;
};
pagination: {
page: number;
pageSize: number;
};
filters?: any;
publicationState?: "live" | "preview";
}
const productVariantParams: ProductVariantParams = {
populate: {
pattern: { fields: ["id"] },
cover: { fields: ["id"] },
ruling: { fields: ["id"] },
pages: { fields: ["id"] },
images: {
populate: {
images: {
fields: ["formats", "url"]
}
}
}
},
pagination: { page: 1, pageSize: 999 },
publicationState: "live"
};
/**
* Product controller with custom actions
*/
export default factories.createCoreController("api::product.product", ({ strapi }) => ({
/**
* Find a single product by ID
*/
findOne: async (ctx) => {
const { id } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::product.product"));
const productUnsafe = await strapi.service("api::product.product").findOne(id, productDefaultParams);
const product = (await sanitize.contentAPI.output(productUnsafe, strapi.getModel("api::product.product"))) as Product;
return {
...product,
totalProductPrice: calculateTotalProductPrice(product)
};
},
/**
* Find products with the lowest price ruling and pages
*/
find: async (ctx): Promise<{ data: Result<"api::product.product">[]; meta: { pagination: any } }> => {
try {
const params = await sanitize.contentAPI.query(ctx.query, strapi.getModel("api::product.product"));
const filtersParams = (params?.filters as Record<string, any>) ?? {};
const populateParams = (params?.populate as Record<string, any>) ?? {};
const paginationParams = (params?.pagination as Record<string, any>) ?? {};
const mergedParams = {
...productDefaultParams,
filters: filtersParams,
populate: {
...productDefaultParams.populate,
...populateParams
},
pagination: paginationParams
};
strapi.log.debug(JSON.stringify({ mergedParams }));
const response = await strapi.service("api::product.product").find(mergedParams);
const { pagination } = response;
const dataUnsafe = (await sanitize.contentAPI.output(
response.results,
strapi.getModel("api::product.product")
)) as Result<"api::product.product">[];
const data = dataUnsafe.map((product: Product) => {
return {
...product,
totalProductPrice: calculateTotalProductPrice(product)
};
});
return { data, meta: { pagination } };
} catch (error) {
strapi.log.error(error);
return ctx.badRequest("Could not fetch products");
}
},
/**
* Get all variants for a product
* @returns {Promise<Product[]>} All variants of the product
*/
allVariants: async (ctx): Promise<Product[]> => {
try {
const { id } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::product.product"));
strapi.log.verbose(`Fetching variants for product ${id}`);
const product = await getProductDetails(id as ID);
const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams,
filters: {
pattern: { id: { $eq: product.pattern.id } }
}
});
if (!variants) {
return ctx.notFound(`Could not find variants for product ${id}`);
}
return Promise.all(variants.map((variant) => sanitize.contentAPI.output(variant, strapi.getModel("api::product.product")))) as Promise<
Product[]
>;
} catch (error) {
strapi.log.error(error);
return ctx.badRequest("Could not fetch product variants");
}
},
/**
* Get variants by pattern for a product
* @returns {Promise<{ allProductPattern: ProductPattern[], productVariants: Product[], patterns: Array<ProductPattern & { productVariant: Product | undefined }> }>} Variants grouped by pattern
*/
variantsByPattern: async (
ctx
): Promise<{
allProductPattern: ProductPattern[];
productVariants: Product[];
patterns: Array<ProductPattern & { productVariant: Product | undefined }>;
}> => {
try {
const { id } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::product.product"));
strapi.log.verbose(`Fetching variants by pattern for product ${id}`);
const product = await getProductDetails(id as ID);
const { pattern, cover, ruling, pages } = product;
const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams,
filters: {
$and: [
{ id: { $ne: id } },
{ pattern: { id: { $ne: pattern.id } } },
{ cover: { id: { $eq: cover.id } } },
{ ruling: { id: { $eq: ruling.id } } },
{ pages: { id: { $eq: pages.id } } }
]
}
});
const sanitizedVariants = (await Promise.all(
productVariants.map((variant) => sanitize.contentAPI.output(variant, strapi.getModel("api::product.product")))
)) as Product[];
const allProductPatternResponse = (await strapi.service("api::product-pattern.product-pattern").find({
fields: ["id", "name", "description"],
populate: { image: { fields: ["url"] } },
filters: { id: { $ne: pattern.id } },
publicationState: "live"
})) as PaginatedResult<"api::product-pattern.product-pattern">;
const allProductPattern = allProductPatternResponse.results;
return {
allProductPattern,
productVariants: sanitizedVariants,
patterns: allProductPattern.map((pattern) => ({
...pattern,
productVariant: sanitizedVariants.find((variant) => variant?.pattern?.id === pattern.id)
}))
};
} catch (error) {
strapi.log.error(error);
return ctx.badRequest("Could not fetch variants by pattern");
}
},
/**
* Get variants for a product
* @returns {Promise<{ pages: any[], cover: any[], ruling: any[] }>} Variants grouped by pages, cover, and ruling
*/
variants: async (
ctx
): Promise<{
pages: any;
cover: any;
ruling: any;
}> => {
try {
const { id } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::product.product"));
strapi.log.verbose(`Fetching variants for product ${id}`);
const product = await getProductDetails(id as ID);
const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>(
"api::product.product",
{
...productVariantParams,
filters: {
$and: [{ pattern: { id: { $eq: product.pattern.id } } }, { id: { $ne: id } }]
}
}
);
const [allProductPages, allProductRulings, allProductCovers] = await Promise.all([
strapi
.service("api::product-page.product-page")
.find({ fields: ["id", "name"] }) as PaginatedResult<"api::product-pattern.product-pattern">,
strapi.service("api::product-ruling.product-ruling").find({
fields: ["id", "name"],
populate: { icon: { fields: ["url"] } }
}) as PaginatedResult<"api::product-ruling.product-ruling">,
strapi.service("api::product-cover.product-cover").find({
fields: ["id", "name", "binding", "price"],
populate: { icon: { fields: ["url"] } }
}) as PaginatedResult<"api::product-cover.product-cover">
]);
const sanitizedVariants = (await Promise.all(
allProductPatternVariants.map((variant) => sanitize.contentAPI.output(variant, strapi.getModel("api::product.product")))
)) as Product[];
// For pages variants: find products with same pattern, cover, and ruling, but different pages
const pagesVariants = allProductPages.results.map((pages) => ({
...pages,
productVariant: sanitizedVariants.find(
(variant) =>
variant.id !== product.id &&
variant.pages.id === pages.id &&
variant.cover.id === product.cover.id &&
variant.ruling.id === product.ruling.id
)
}));
// For cover variants: find products with same pattern and ruling but different covers
const coverVariants = allProductCovers.results.map((cover) => {
// Find any product with this cover and the same pattern
const matchingVariant = sanitizedVariants.find(
(variant) =>
variant.id !== product.id &&
variant.cover.id === cover.id &&
variant.pattern.id === product.pattern.id &&
variant.ruling.id === product.ruling.id
);
return {
...cover,
productVariant: matchingVariant
};
});
// For ruling variants: find products with same pattern, cover, and pages, but different ruling
const rulingVariants = allProductRulings.results.map((ruling) => ({
...ruling,
productVariant: sanitizedVariants.find(
(variant) =>
variant.id !== product.id &&
variant.ruling.id === ruling.id &&
variant.pages.id === product.pages.id &&
variant.cover.id === product.cover.id
)
}));
return {
pages: pagesVariants,
cover: coverVariants,
ruling: rulingVariants
};
} catch (error) {
strapi.log.error(error);
return ctx.badRequest("Could not fetch product variants");
}
},
/**
* Publish or unpublish products by filter
* @returns {Promise<{published: number, dryRun: boolean}>} The number of products published/unpublished and dryRun status
*/
publishByFilter: async (ctx): Promise<{ published: number; dryRun: boolean }> => {
try {
const { filters, publish = true, dryRun = false } = ctx.request.body;
if (!filters) {
return ctx.badRequest("Filters are required");
}
strapi.log.debug(`Publishing products with filters: ${JSON.stringify(filters)}, publish: ${publish}, dryRun: ${dryRun}`);
return await strapi.service("api::product.product").publishByFilter(filters, publish, dryRun);
} catch (error) {
strapi.log.error(error);
return ctx.badRequest("Could not publish products");
}
},
/**
* Find products with only the cheapest variant per cover+pattern combination
*/
findCheapest: async (ctx): Promise<{ data: Result<"api::product.product">[]; meta: { pagination: any } }> => {
try {
const params = await sanitize.contentAPI.query(ctx.query, strapi.getModel("api::product.product"));
const filtersParams = (params?.filters as Record<string, any>) ?? {};
const populateParams = (params?.populate as Record<string, any>) ?? {};
const paginationParams = (params?.pagination as Record<string, any>) ?? {};
// First get all products
const mergedParams = {
...productDefaultParams,
filters: filtersParams,
populate: {
...productDefaultParams.populate,
...populateParams
},
pagination: { pageSize: 999 } // Temporarily get all to filter
};
const allProducts = await strapi.service("api::product.product").find(mergedParams);
const allProductsSanitized = (await sanitize.contentAPI.output(
allProducts.results,
strapi.getModel("api::product.product")
)) as Product[];
// Group products by cover+pattern combination
const productGroups = new Map();
allProductsSanitized.forEach((product) => {
const key = `${product.cover.id}-${product.pattern.id}`;
if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) {
productGroups.set(key, product);
}
});
// Convert map values to array and sort by pattern.id
const cheapestProducts = Array.from(productGroups.values()).sort((a, b) => {
const idA = a.pattern?.id ?? 0;
const idB = b.pattern?.id ?? 0;
return idA - idB;
});
// Apply pagination manually
const page = parseInt(paginationParams.page) || 1;
const pageSize = parseInt(paginationParams.pageSize) || 24;
const total = cheapestProducts.length;
const pageCount = Math.ceil(total / pageSize);
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedProducts = cheapestProducts.slice(start, end);
// Add total price to each product
const data = paginatedProducts.map((product) => ({
...product,
totalProductPrice: calculateTotalProductPrice(product)
}));
return {
data,
meta: {
pagination: {
page,
pageSize,
pageCount,
total
}
}
};
} catch (error) {
strapi.log.error(error);
return ctx.badRequest("Could not fetch cheapest products");
}
},
/**
* Get all published product paths for sitemap generation
* @returns {Promise<string[]>} Array of product paths
*/
sitemap: async (ctx): Promise<string[]> => {
try {
strapi.log.verbose("Generating product sitemap");
const products = await strapi.entityService.findMany("api::product.product", {
fields: ["slug"],
filters: {
publishedAt: { $notNull: true }
},
pagination: {
page: 1,
pageSize: maxProductsSitemap
},
sort: { updatedAt: "desc" }
});
if (!products || !products.length) {
return [];
}
const productPaths = products.map((product) => `/details/${product.slug}`);
strapi.log.verbose(`Generated product sitemap with ${productPaths.length} entries`);
strapi.log.silly(`Product sitemap: ${JSON.stringify(productPaths)}`);
return productPaths;
} catch (error) {
strapi.log.error(error);
return ctx.badRequest("Could not generate product sitemap");
}
}
}));
/**
* Helper function to get product details
* @param {ID} id - The product ID
* @returns {Promise<Product>} The product details
* @throws {Error} If the product is not found
*/
async function getProductDetails(id: ID): Promise<Product> {
const product = await strapi.entityService.findOne("api::product.product", id, {
fields: ["id", "name"],
populate: {
pattern: { fields: ["id"] },
cover: { fields: ["id"] },
ruling: { fields: ["id"] },
pages: { fields: ["id"] }
}
});
if (!product) {
throw new Error(`Could not find product with ID ${id}`);
}
return (await sanitize.contentAPI.output(product, strapi.getModel("api::product.product"))) as Promise<Product>;
}