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.
443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
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>;
|
|
}
|