feat: extract cms from mp/cms — initial libreshop/cms
Some checks failed
Build and publish / build (push) Failing after 17s
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:
442
src/api/product/controllers/product.ts
Normal file
442
src/api/product/controllers/product.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user