diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 96cae353c..ba2b7dbe4 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -32,9 +32,9 @@ export type Context = UnchainedCore & { version?: string; roles?: any; adminUiConfig?: AdminUiConfig; + loaders: UnchainedLoaders; } & UnchainedUserContext & UnchainedLocaleContext & - UnchainedLoaders & UnchainedHTTPServerContext; let context; diff --git a/packages/api/src/loaders/index.ts b/packages/api/src/loaders/index.ts index 2aeb6dcef..358db7cbb 100644 --- a/packages/api/src/loaders/index.ts +++ b/packages/api/src/loaders/index.ts @@ -13,48 +13,6 @@ import { } from '@unchainedshop/core-assortments'; import { File } from '@unchainedshop/core-files'; -export interface UnchainedLoaders { - loaders: { - productLoader: InstanceType>; - productLoaderBySKU: InstanceType>; - productTextLoader: InstanceType< - typeof DataLoader<{ productId: string; locale: string }, ProductText> - >; - productMediaTextLoader: InstanceType< - typeof DataLoader<{ productMediaId: string; locale: string }, ProductMediaText> - >; - - fileLoader: InstanceType>; - - filterLoader: InstanceType>; - filterTextLoader: InstanceType< - typeof DataLoader<{ filterId: string; filterOptionValue?: string; locale: string }, FilterText> - >; - - assortmentLoader: InstanceType>; - assortmentTextLoader: InstanceType< - typeof DataLoader<{ assortmentId: string; locale: string }, AssortmentText> - >; - assortmentLinkLoader: InstanceType< - typeof DataLoader<{ parentAssortmentId: string; childAssortmentId: string }, AssortmentLink> - >; - assortmentLinksLoader: InstanceType< - typeof DataLoader<{ parentAssortmentId?: string; assortmentId?: string }, AssortmentLink[]> - >; - assortmentProductLoader: InstanceType< - typeof DataLoader<{ assortmentId: string; productId: string }, AssortmentProduct> - >; - assortmentMediaTextLoader: InstanceType< - typeof DataLoader<{ assortmentMediaId: string; locale: string }, AssortmentMediaText> - >; - - productMediasLoader: InstanceType>; - assortmentMediasLoader: InstanceType< - typeof DataLoader<{ assortmentId?: string }, AssortmentMediaType[]> - >; - }; -} - function getLocaleStrings(locale: string) { const localeObj = new Intl.Locale(locale); return [ @@ -67,9 +25,9 @@ function getLocaleStrings(locale: string) { ]; } -const loaders = async (unchainedAPI: UnchainedCore): Promise => { +const loaders = async (unchainedAPI: UnchainedCore) => { return { - assortmentLoader: new DataLoader(async (queries) => { + assortmentLoader: new DataLoader<{ assortmentId: string }, Assortment>(async (queries) => { const assortmentIds = [...new Set(queries.map((q) => q.assortmentId).filter(Boolean))]; const assortments = await unchainedAPI.modules.assortments.findAssortments({ @@ -85,33 +43,38 @@ const loaders = async (unchainedAPI: UnchainedCore): Promise assortmentMap[q.assortmentId]); }), - assortmentTextLoader: new DataLoader(async (queries) => { - const assortmentIds = [...new Set(queries.map((q) => q.assortmentId).filter(Boolean))]; + assortmentTextLoader: new DataLoader<{ assortmentId: string; locale: string }, AssortmentText>( + async (queries) => { + const assortmentIds = [...new Set(queries.map((q) => q.assortmentId).filter(Boolean))]; - const texts = await unchainedAPI.modules.assortments.texts.findTexts( - { assortmentId: { $in: assortmentIds } }, - { - sort: { - assortmentId: 1, + const texts = await unchainedAPI.modules.assortments.texts.findTexts( + { assortmentId: { $in: assortmentIds } }, + { + sort: { + assortmentId: 1, + }, }, - }, - ); - - const queryLocales = [...new Set(queries.map((q) => q.locale.toLowerCase()))]; - const textsMap = {}; - const localeMap = Object.fromEntries( - queryLocales.flatMap((queryLocale) => { - return getLocaleStrings(queryLocale).map((locale) => [locale, queryLocale]); - }), - ); - for (const text of texts) { - const locale = localeMap[text.locale.toLowerCase()]; - textsMap[locale + text.assortmentId] = text; - } - return queries.map((q) => textsMap[q.locale.toLowerCase() + q.assortmentId]); - }), - - assortmentMediaTextLoader: new DataLoader(async (queries) => { + ); + + const queryLocales = [...new Set(queries.map((q) => q.locale.toLowerCase()))]; + const textsMap = {}; + const localeMap = Object.fromEntries( + queryLocales.flatMap((queryLocale) => { + return getLocaleStrings(queryLocale).map((locale) => [locale, queryLocale]); + }), + ); + for (const text of texts) { + const locale = localeMap[text.locale.toLowerCase()]; + textsMap[locale + text.assortmentId] = text; + } + return queries.map((q) => textsMap[q.locale.toLowerCase() + q.assortmentId]); + }, + ), + + assortmentMediaTextLoader: new DataLoader< + { assortmentMediaId: string; locale: string }, + AssortmentMediaText + >(async (queries) => { const assortmentMediaIds = [...new Set(queries.map((q) => q.assortmentMediaId).filter(Boolean))]; const texts = await unchainedAPI.modules.assortments.media.texts.findMediaTexts( @@ -137,92 +100,136 @@ const loaders = async (unchainedAPI: UnchainedCore): Promise textsMap[q.locale.toLowerCase() + q.assortmentMediaId]); }), - assortmentMediasLoader: new DataLoader(async (queries) => { - const assortmentIds = [...new Set(queries.map((q) => q.assortmentId).filter(Boolean))]; - const assortmentMediaItems = await unchainedAPI.modules.assortments.media.findAssortmentMedias({ - assortmentId: { $in: assortmentIds }, - }); + assortmentMediasLoader: new DataLoader<{ assortmentId?: string }, AssortmentMediaType[]>( + async (queries) => { + const assortmentIds = [...new Set(queries.map((q) => q.assortmentId).filter(Boolean))]; + const assortmentMediaItems = await unchainedAPI.modules.assortments.media.findAssortmentMedias({ + assortmentId: { $in: assortmentIds }, + }); - const assortmentMediaMap = {}; - for (const assortmentMedia of assortmentMediaItems) { - if (!assortmentMediaMap[assortmentMedia.assortmentId]) { - assortmentMediaMap[assortmentMedia.assortmentId] = [assortmentMedia]; - } else { - assortmentMediaMap[assortmentMedia.assortmentId].push(assortmentMedia); + const assortmentMediaMap = {}; + for (const assortmentMedia of assortmentMediaItems) { + if (!assortmentMediaMap[assortmentMedia.assortmentId]) { + assortmentMediaMap[assortmentMedia.assortmentId] = [assortmentMedia]; + } else { + assortmentMediaMap[assortmentMedia.assortmentId].push(assortmentMedia); + } } - } - return queries.map((q) => assortmentMediaMap[q.assortmentId] || []); - }), - - assortmentLinkLoader: new DataLoader(async (queries) => { + return queries.map((q) => assortmentMediaMap[q.assortmentId] || []); + }, + ), + + assortmentLinkLoader: new DataLoader< + { parentAssortmentId: string; childAssortmentId: string }, + AssortmentLink + >(async (queries) => { const parentAssortmentIds = [...new Set(queries.map((q) => q.parentAssortmentId).filter(Boolean))]; const links = await unchainedAPI.modules.assortments.links.findLinks({ parentAssortmentIds, }); - // TODO: Optimize + const assortmentLinkMap = {}; + for (const link of links) { + if (!assortmentLinkMap[link.parentAssortmentId]) { + assortmentLinkMap[link.parentAssortmentId] = [link]; + } else { + assortmentLinkMap[link.parentAssortmentId].push(link); + } + } + return queries.map((q) => { - return links.find((link) => { - if (link.parentAssortmentId !== q.parentAssortmentId) return false; - if (q.childAssortmentId && link.childAssortmentId !== q.childAssortmentId) return false; - return true; - }); + if (q.childAssortmentId) { + return assortmentLinkMap[q.parentAssortmentId].find( + (link) => link.childAssortmentId === q.childAssortmentId, + ); + } + return assortmentLinkMap[q.parentAssortmentId][0]; }); }), - assortmentLinksLoader: new DataLoader(async (queries) => { - const parentAssortmentIds = [ - ...new Set(queries.flatMap((q) => q.parentAssortmentId).filter(Boolean)), - ]; - const assortmentIds = [...new Set(queries.flatMap((q) => q.assortmentId).filter(Boolean))]; - - const linksByParentAssortmentId = - parentAssortmentIds?.length && - (await unchainedAPI.modules.assortments.links.findLinks({ - parentAssortmentIds, - })); - const linksByAssortmentId = - assortmentIds?.length && - (await unchainedAPI.modules.assortments.links.findLinks({ - assortmentIds, - })); - - // TODO: Optimize + assortmentLinksLoader: new DataLoader< + { parentAssortmentId?: string; childAssortmentId?: string; assortmentId?: string }, + AssortmentLink[] + >(async (queries) => { + const parentAssortmentIds = queries.flatMap((q) => q.parentAssortmentId).filter(Boolean); + const childAssortmentIds = queries.flatMap((q) => q.childAssortmentId).filter(Boolean); + const assortmentIds = queries.flatMap((q) => q.assortmentId).filter(Boolean); + + const allLinks = await unchainedAPI.modules.assortments.links.findLinks({ + assortmentIds: [...new Set([...parentAssortmentIds, ...childAssortmentIds, ...assortmentIds])], + }); + + const parentAssortmentLinkMap = {}; + const childAssortmentLinkMap = {}; + + for (const link of allLinks) { + if (!parentAssortmentLinkMap[link.parentAssortmentId]) { + parentAssortmentLinkMap[link.parentAssortmentId] = [link]; + } else { + parentAssortmentLinkMap[link.parentAssortmentId].push(link); + } + + if (!childAssortmentLinkMap[link.childAssortmentId]) { + childAssortmentLinkMap[link.childAssortmentId] = [link]; + } else { + childAssortmentLinkMap[link.childAssortmentId].push(link); + } + } + return queries.map((q) => { if (q.parentAssortmentId) { - return linksByParentAssortmentId.filter( - (link) => link.parentAssortmentId === q.parentAssortmentId, - ); - } - if (q.assortmentId) { - return linksByAssortmentId.filter( - (link) => - link.parentAssortmentId === q.assortmentId || link.childAssortmentId === q.assortmentId, - ); + return parentAssortmentLinkMap[q.parentAssortmentId] || []; + } else if (q.childAssortmentId) { + return childAssortmentLinkMap[q.childAssortmentId] || []; } - return []; + return [ + ...(parentAssortmentLinkMap[q.assortmentId] || []), + ...(childAssortmentLinkMap[q.assortmentId] || []), + ]; }); }), - assortmentProductLoader: new DataLoader(async (queries) => { + assortmentProductLoader: new DataLoader< + { assortmentId: string; productId: string }, + AssortmentProduct + >(async (queries) => { const assortmentIds = [...new Set(queries.map((q) => q.assortmentId).filter(Boolean))]; - const assortmentProducts = await unchainedAPI.modules.assortments.products.findProducts({ + const assortmentProducts = await unchainedAPI.modules.assortments.products.findAssortmentProducts({ assortmentIds, }); - // TODO: Optimize - return queries.map((q) => { - return assortmentProducts.find((assortmentProduct) => { - if (assortmentProduct.assortmentId !== q.assortmentId) return false; - if (assortmentProduct.productId !== q.productId) return false; - return true; - }); - }); + const assortmentProductMap = {}; + for (const assortmentProduct of assortmentProducts) { + assortmentProductMap[assortmentProduct.assortmentId + assortmentProduct.productId] = + assortmentProduct; + } + return queries.map((q) => assortmentProductMap[q.assortmentId + q.productId]); }), - filterLoader: new DataLoader(async (queries) => { + assortmentProductsLoader: new DataLoader<{ productId: string }, AssortmentProduct[]>( + async (queries) => { + const productIds = [...new Set(queries.map((q) => q.productId).filter(Boolean))]; + + const assortmentProducts = + await unchainedAPI.modules.assortments.products.findAssortmentProducts({ + productIds, + }); + + const assortmentProductsMap = {}; + for (const assortmentProduct of assortmentProducts) { + if (!assortmentProductsMap[assortmentProduct.productId]) { + assortmentProductsMap[assortmentProduct.productId] = [assortmentProduct]; + } else { + assortmentProductsMap[assortmentProduct.productId].push(assortmentProduct); + } + } + return queries.map((q) => assortmentProductsMap[q.productId] || []); + }, + ), + + filterLoader: new DataLoader<{ filterId: string }, Filter>(async (queries) => { const filterIds = [...new Set(queries.map((q) => q.filterId).filter(Boolean))]; const filters = await unchainedAPI.modules.filters.findFilters({ @@ -238,7 +245,10 @@ const loaders = async (unchainedAPI: UnchainedCore): Promise filterMap[q.filterId]); }), - filterTextLoader: new DataLoader(async (queries) => { + filterTextLoader: new DataLoader< + { filterId: string; filterOptionValue?: string; locale: string }, + FilterText + >(async (queries) => { const filterIds = [...new Set(queries.map((q) => q.filterId).filter(Boolean))]; const texts = await unchainedAPI.modules.filters.texts.findTexts( @@ -264,7 +274,7 @@ const loaders = async (unchainedAPI: UnchainedCore): Promise textsMap[q.locale.toLowerCase() + q.filterId + q.filterOptionValue]); }), - productLoader: new DataLoader(async (queries) => { + productLoader: new DataLoader<{ productId: string }, Product>(async (queries) => { const productIds = [...new Set(queries.map((q) => q.productId).filter(Boolean))]; // you don't need lodash, _.unique my ass const products = await unchainedAPI.modules.products.findProducts({ @@ -282,7 +292,7 @@ const loaders = async (unchainedAPI: UnchainedCore): Promise productMap[q.productId]); }), - productLoaderBySKU: new DataLoader(async (queries) => { + productLoaderBySKU: new DataLoader<{ sku: string }, Product>(async (queries) => { const skus = [...new Set(queries.map((q) => q.sku).filter(Boolean))]; // you don't need lodash, _.unique my ass const products = await unchainedAPI.modules.products.findProducts({ @@ -300,57 +310,61 @@ const loaders = async (unchainedAPI: UnchainedCore): Promise productMap[q.sku]); }), - productTextLoader: new DataLoader(async (queries) => { - const productIds = [...new Set(queries.map((q) => q.productId))].filter(Boolean); - const texts = await unchainedAPI.modules.products.texts.findTexts( - { productId: { $in: productIds } }, - { - sort: { - productId: 1, + productTextLoader: new DataLoader<{ productId: string; locale: string }, ProductText>( + async (queries) => { + const productIds = [...new Set(queries.map((q) => q.productId))].filter(Boolean); + const texts = await unchainedAPI.modules.products.texts.findTexts( + { productId: { $in: productIds } }, + { + sort: { + productId: 1, + }, }, - }, - ); - const queryLocales = [...new Set(queries.map((q) => q.locale.toLowerCase()))]; - const textsMap = {}; - const localeMap = Object.fromEntries( - queryLocales.flatMap((queryLocale) => { - return getLocaleStrings(queryLocale).map((locale) => [locale, queryLocale]); - }), - ); - for (const text of texts) { - const locale = localeMap[text.locale.toLowerCase()]; - textsMap[locale + text.productId] = text; - } - return queries.map((q) => textsMap[q.locale.toLowerCase() + q.productId]); - }), - - productMediaTextLoader: new DataLoader(async (queries) => { - const productMediaIds = [...new Set(queries.map((q) => q.productMediaId).filter(Boolean))]; - - const texts = await unchainedAPI.modules.products.media.texts.findMediaTexts( - { productMediaId: { $in: productMediaIds } }, - { - sort: { - productMediaId: 1, + ); + const queryLocales = [...new Set(queries.map((q) => q.locale.toLowerCase()))]; + const textsMap = {}; + const localeMap = Object.fromEntries( + queryLocales.flatMap((queryLocale) => { + return getLocaleStrings(queryLocale).map((locale) => [locale, queryLocale]); + }), + ); + for (const text of texts) { + const locale = localeMap[text.locale.toLowerCase()]; + textsMap[locale + text.productId] = text; + } + return queries.map((q) => textsMap[q.locale.toLowerCase() + q.productId]); + }, + ), + + productMediaTextLoader: new DataLoader<{ productMediaId: string; locale: string }, ProductMediaText>( + async (queries) => { + const productMediaIds = [...new Set(queries.map((q) => q.productMediaId).filter(Boolean))]; + + const texts = await unchainedAPI.modules.products.media.texts.findMediaTexts( + { productMediaId: { $in: productMediaIds } }, + { + sort: { + productMediaId: 1, + }, }, - }, - ); - - const queryLocales = [...new Set(queries.map((q) => q.locale.toLowerCase()))]; - const textsMap = {}; - const localeMap = Object.fromEntries( - queryLocales.flatMap((queryLocale) => { - return getLocaleStrings(queryLocale).map((locale) => [locale, queryLocale]); - }), - ); - for (const text of texts) { - const locale = localeMap[text.locale.toLowerCase()]; - textsMap[locale + text.productMediaId] = text; - } - return queries.map((q) => textsMap[q.locale.toLowerCase() + q.productMediaId]); - }), + ); + + const queryLocales = [...new Set(queries.map((q) => q.locale.toLowerCase()))]; + const textsMap = {}; + const localeMap = Object.fromEntries( + queryLocales.flatMap((queryLocale) => { + return getLocaleStrings(queryLocale).map((locale) => [locale, queryLocale]); + }), + ); + for (const text of texts) { + const locale = localeMap[text.locale.toLowerCase()]; + textsMap[locale + text.productMediaId] = text; + } + return queries.map((q) => textsMap[q.locale.toLowerCase() + q.productMediaId]); + }, + ), - productMediasLoader: new DataLoader(async (queries) => { + productMediasLoader: new DataLoader<{ productId?: string }, ProductMedia[]>(async (queries) => { const productIds = [...new Set(queries.map((q) => q.productId).filter(Boolean))]; const productMediaItems = await unchainedAPI.modules.products.media.findProductMedias({ productId: { $in: productIds }, @@ -367,7 +381,7 @@ const loaders = async (unchainedAPI: UnchainedCore): Promise productMediaMap[q.productId] || []); }), - fileLoader: new DataLoader(async (queries) => { + fileLoader: new DataLoader<{ fileId: string }, File>(async (queries) => { const fileIds = [...new Set(queries.map((q) => q.fileId).filter(Boolean))]; // you don't need lodash, _.unique my ass const files = await unchainedAPI.modules.files.findFiles({ @@ -384,4 +398,6 @@ const loaders = async (unchainedAPI: UnchainedCore): Promise>; + export default loaders; diff --git a/packages/api/src/resolvers/mutations/assortments/removeAssortmentProduct.ts b/packages/api/src/resolvers/mutations/assortments/removeAssortmentProduct.ts index bfb93d7ce..3b2cbf245 100644 --- a/packages/api/src/resolvers/mutations/assortments/removeAssortmentProduct.ts +++ b/packages/api/src/resolvers/mutations/assortments/removeAssortmentProduct.ts @@ -13,7 +13,7 @@ export default async function removeAssortmentProduct( if (!assortmentProductId) throw new InvalidIdError({ assortmentProductId }); - const assortmentProduct = await modules.assortments.products.findProduct({ + const assortmentProduct = await modules.assortments.products.findAssortmentProduct({ assortmentProductId, }); if (!assortmentProduct) throw new AssortmentProductNotFoundError({ assortmentProductId }); diff --git a/packages/api/src/resolvers/type/assortment/assortment-path-link-types.ts b/packages/api/src/resolvers/type/assortment/assortment-path-link-types.ts index 1484e92c2..a21cc7642 100644 --- a/packages/api/src/resolvers/type/assortment/assortment-path-link-types.ts +++ b/packages/api/src/resolvers/type/assortment/assortment-path-link-types.ts @@ -1,28 +1,23 @@ +import { AssortmentLink } from '@unchainedshop/core-assortments'; import { Context } from '../../../context.js'; -import { - AssortmentPathLink as AssortmentPathLinkType, - AssortmentLink as AssortmentLinkType, - AssortmentText, -} from '@unchainedshop/core-assortments'; -type HelperType = (assortmentPathLink: AssortmentPathLinkType, params: P, context: Context) => T; - -export interface AssortmentPathLinkHelperTypes { - link: HelperType>; - assortmentTexts: HelperType<{ forceLocale?: string }, Promise>; -} +export const AssortmentPathLink = { + link(linkObj) { + if (linkObj._id) return linkObj; + return null; + }, -export const AssortmentPathLink: AssortmentPathLinkHelperTypes = { - link: async ({ assortmentId, childAssortmentId }, _, { loaders }) => { - return loaders.assortmentLinkLoader.load({ - parentAssortmentId: assortmentId, - childAssortmentId, - }); + assortmentId(linkObj) { + return linkObj.childAssortmentId; }, - assortmentTexts: async ({ assortmentId }, params, { loaders, localeContext }) => { + assortmentTexts: async ( + { childAssortmentId }: AssortmentLink, + params: { forceLocale?: string }, + { loaders, localeContext }: Context, + ) => { const text = await loaders.assortmentTextLoader.load({ - assortmentId, + assortmentId: childAssortmentId, locale: params.forceLocale || localeContext.baseName, }); return text; diff --git a/packages/api/src/resolvers/type/assortment/assortment-types.ts b/packages/api/src/resolvers/type/assortment/assortment-types.ts index a3df806c5..e71aa250b 100644 --- a/packages/api/src/resolvers/type/assortment/assortment-types.ts +++ b/packages/api/src/resolvers/type/assortment/assortment-types.ts @@ -3,10 +3,22 @@ import { Assortment } from '@unchainedshop/core-assortments'; import { SearchFilterQuery } from '@unchainedshop/core-filters'; export const AssortmentTypes = { - assortmentPaths: (obj: Assortment, _, { modules }: Context) => { - return modules.assortments.breadcrumbs({ - assortmentId: obj._id, - }); + assortmentPaths(obj: Assortment, _, { modules, loaders }: Context) { + return modules.assortments.breadcrumbs( + { + assortmentId: obj._id, + }, + { + resolveAssortmentProducts: async (productId) => + loaders.assortmentProductsLoader.load({ + productId, + }), + resolveAssortmentLinks: async (childAssortmentId) => + loaders.assortmentLinksLoader.load({ + childAssortmentId, + }), + }, + ); }, children: async ( @@ -79,7 +91,7 @@ export const AssortmentTypes = { async productAssignments(obj: Assortment, _, { modules }: Context) { // TODO: Loader & move default sort to core module - return modules.assortments.products.findProducts( + return modules.assortments.products.findAssortmentProducts( { assortmentId: obj._id, }, diff --git a/packages/api/src/resolvers/type/product/product-types.ts b/packages/api/src/resolvers/type/product/product-types.ts index 8e0e8fe94..b9eb47b62 100644 --- a/packages/api/src/resolvers/type/product/product-types.ts +++ b/packages/api/src/resolvers/type/product/product-types.ts @@ -18,15 +18,27 @@ export const Product = { params: { forceLocale?: string; }, - { modules }: Context, + { modules, loaders }: Context, ): Promise< Array<{ links: Array; }> > { - return modules.assortments.breadcrumbs({ - productId: product._id, - }); + return modules.assortments.breadcrumbs( + { + productId: product._id, + }, + { + resolveAssortmentProducts: async (productId) => + loaders.assortmentProductsLoader.load({ + productId, + }), + resolveAssortmentLinks: async (childAssortmentId) => + loaders.assortmentLinksLoader.load({ + childAssortmentId, + }), + }, + ); }, // TODO: Use a loader! @@ -103,7 +115,7 @@ export const Product = { if (!assortmentIds.length) return []; - const productIds = await modules.assortments.products.findProductSiblings({ + const productIds = await modules.assortments.products.findSiblings({ productId, assortmentIds, }); diff --git a/packages/core-assortments/src/module/configureAssortmentLinksModule.ts b/packages/core-assortments/src/module/configureAssortmentLinksModule.ts index e6a249db3..5453d0093 100644 --- a/packages/core-assortments/src/module/configureAssortmentLinksModule.ts +++ b/packages/core-assortments/src/module/configureAssortmentLinksModule.ts @@ -1,7 +1,6 @@ import { emit, registerEvents } from '@unchainedshop/events'; import { generateDbFilterById, generateDbObjectId, mongodb } from '@unchainedshop/mongodb'; import { walkUpFromAssortment } from '../utils/breadcrumbs/build-paths.js'; -import { resolveAssortmentLinkFromDatabase } from '../utils/breadcrumbs/resolveAssortmentLinkFromDatabase.js'; import { AssortmentLink, InvalidateCacheFn } from '../db/AssortmentsCollection.js'; const ASSORTMENT_LINK_EVENTS = [ @@ -110,33 +109,36 @@ export const configureAssortmentLinksModule = ({ create: async (doc, options) => { const { _id: assortmentLinkId, parentAssortmentId, childAssortmentId, sortKey, ...rest } = doc; - const selector = { - ...(assortmentLinkId ? generateDbFilterById(assortmentLinkId) : {}), - parentAssortmentId, - childAssortmentId, - }; + const assortmentLinksPath = await walkUpFromAssortment({ + resolveAssortmentLinks: async (id: string) => { + return AssortmentLinks.find( + { childAssortmentId: id }, + { + projection: { _id: 1, childAssortmentId: 1, parentAssortmentId: 1 }, + sort: { sortKey: 1, parentAssortmentId: 1 }, + }, + ).toArray(); + }, + assortmentId: parentAssortmentId, + }); + const assortmentIdAlreadyPartOfGraphParents = assortmentLinksPath.some((path) => + path.links?.some( + (l) => l.parentAssortmentId === childAssortmentId || l.childAssortmentId === childAssortmentId, + ), + ); + if (assortmentIdAlreadyPartOfGraphParents) throw Error('CyclicGraphNotSupported'); - const $set: any = { + const $set: mongodb.UpdateFilter = { updated: new Date(), ...rest, }; - const $setOnInsert: any = { + const $setOnInsert: mongodb.UpdateFilter = { _id: assortmentLinkId || generateDbObjectId(), parentAssortmentId, childAssortmentId, created: new Date(), }; - const assortmentLinksPath = await walkUpFromAssortment({ - resolveAssortmentLink: resolveAssortmentLinkFromDatabase(AssortmentLinks), - assortmentId: parentAssortmentId, - }); - assortmentLinksPath - .flatMap(({ links }) => links) - .forEach(({ parentIds }) => { - if (parentIds.includes(childAssortmentId)) throw Error('CyclicGraphNotSupported'); - }); - if (sortKey === undefined || sortKey === null) { // Get next sort key const lastAssortmentLink = (await AssortmentLinks.findOne( @@ -149,7 +151,11 @@ export const configureAssortmentLinksModule = ({ } const assortmentLink = await AssortmentLinks.findOneAndUpdate( - selector, + { + ...(assortmentLinkId ? generateDbFilterById(assortmentLinkId) : {}), + parentAssortmentId, + childAssortmentId, + }, { $set, $setOnInsert, diff --git a/packages/core-assortments/src/module/configureAssortmentProductsModule.ts b/packages/core-assortments/src/module/configureAssortmentProductsModule.ts index f6f5dc186..d392383d0 100644 --- a/packages/core-assortments/src/module/configureAssortmentProductsModule.ts +++ b/packages/core-assortments/src/module/configureAssortmentProductsModule.ts @@ -8,71 +8,24 @@ const ASSORTMENT_PRODUCT_EVENTS = [ 'ASSORTMENT_REORDER_PRODUCTS', ]; -export type AssortmentProductsModule = { - // Queries - findAssortmentIds: (params: { productId: string; tags?: Array }) => Promise>; - findProductIds: (params: { assortmentId: string; tags?: Array }) => Promise>; - - findProduct: (params: { assortmentProductId: string }) => Promise; - - findProducts: ( - params: { - assortmentId?: string; - assortmentIds?: Array; - }, - options?: mongodb.FindOptions, - ) => Promise>; - - findProductSiblings: (params: { - productId: string; - assortmentIds: Array; - }) => Promise>; - - // Mutations - create: ( - doc: AssortmentProduct, - options?: { skipInvalidation?: boolean }, - ) => Promise; - - delete: ( - assortmentProductId: string, - options?: { skipInvalidation?: boolean }, - ) => Promise>; - - deleteMany: ( - selector: mongodb.Filter, - options?: { skipInvalidation?: boolean }, - ) => Promise; - - update: ( - assortmentProductId: string, - doc: AssortmentProduct, - options?: { skipInvalidation?: boolean }, - ) => Promise; - - updateManualOrder: ( - params: { - sortKeys: Array<{ - assortmentProductId: string; - sortKey: number; - }>; - }, - options?: { skipInvalidation?: boolean }, - ) => Promise>; -}; - export const configureAssortmentProductsModule = ({ AssortmentProducts, invalidateCache, }: { AssortmentProducts: mongodb.Collection; invalidateCache: InvalidateCacheFn; -}): AssortmentProductsModule => { +}) => { registerEvents(ASSORTMENT_PRODUCT_EVENTS); return { // Queries - findAssortmentIds: async ({ productId, tags }) => { + findAssortmentIds: async ({ + productId, + tags, + }: { + productId: string; + tags?: Array; + }): Promise> => { const selector: mongodb.Filter = { productId }; if (tags) { selector.tags = { $in: tags }; @@ -82,7 +35,13 @@ export const configureAssortmentProductsModule = ({ .toArray(); }, - findProductIds: async ({ assortmentId, tags }) => { + findProductIds: async ({ + assortmentId, + tags, + }: { + assortmentId: string; + tags?: Array; + }): Promise> => { const selector: mongodb.Filter = { assortmentId }; if (tags) { selector.tags = { $in: tags }; @@ -92,19 +51,46 @@ export const configureAssortmentProductsModule = ({ .toArray(); }, - findProduct: async ({ assortmentProductId }) => { + findAssortmentProduct: async ({ + assortmentProductId, + }: { + assortmentProductId: string; + }): Promise => { return AssortmentProducts.findOne(generateDbFilterById(assortmentProductId), {}); }, - findProducts: async ({ assortmentId, assortmentIds }, options) => { - const products = AssortmentProducts.find( - { assortmentId: assortmentId || { $in: assortmentIds } }, - options, - ); - return products.toArray(); + findAssortmentProducts: async ( + { + productId, + productIds, + assortmentId, + assortmentIds, + }: { + assortmentId?: string; + assortmentIds?: Array; + productId?: string; + productIds?: Array; + }, + options?: mongodb.FindOptions, + ): Promise> => { + const selector: mongodb.Filter = {}; + if (assortmentId || assortmentIds) { + selector.assortmentId = assortmentId || { $in: assortmentIds }; + } + if (productId || productIds) { + selector.productId = productId || { $in: productIds }; + } + const assortmentProducts = AssortmentProducts.find(selector, options); + return assortmentProducts.toArray(); }, - findProductSiblings: async ({ assortmentIds, productId }) => { + findSiblings: async ({ + assortmentIds, + productId, + }: { + productId: string; + assortmentIds: Array; + }): Promise> => { const selector = { assortmentId: { $in: assortmentIds }, }; @@ -120,7 +106,10 @@ export const configureAssortmentProductsModule = ({ }, // Mutations - create: async (doc: AssortmentProduct, options) => { + create: async ( + doc: AssortmentProduct, + options?: { skipInvalidation?: boolean }, + ): Promise => { const { _id, assortmentId, productId, sortKey, ...rest } = doc; const selector = { @@ -170,7 +159,10 @@ export const configureAssortmentProductsModule = ({ return assortmentProduct; }, - delete: async (assortmentProductId, options) => { + delete: async ( + assortmentProductId: string, + options?: { skipInvalidation?: boolean }, + ): Promise> => { const selector = generateDbFilterById(assortmentProductId); const assortmentProduct = await AssortmentProducts.findOneAndDelete(selector); @@ -187,7 +179,10 @@ export const configureAssortmentProductsModule = ({ return [assortmentProduct]; }, - deleteMany: async (selector, options) => { + deleteMany: async ( + selector: mongodb.Filter, + options?: { skipInvalidation?: boolean }, + ): Promise => { const assortmentProducts = await AssortmentProducts.find(selector, { projection: { _id: 1, assortmentId: 1 }, }).toArray(); @@ -211,7 +206,11 @@ export const configureAssortmentProductsModule = ({ }, // This action is specifically used for the bulk migration scripts in the platform package - update: async (assortmentProductId, doc, options) => { + update: async ( + assortmentProductId: string, + doc: AssortmentProduct, + options?: { skipInvalidation?: boolean }, + ): Promise => { const selector = generateDbFilterById(assortmentProductId); const modifier = { $set: { @@ -229,7 +228,17 @@ export const configureAssortmentProductsModule = ({ return assortmentProduct; }, - updateManualOrder: async ({ sortKeys }, options) => { + updateManualOrder: async ( + { + sortKeys, + }: { + sortKeys: Array<{ + assortmentProductId: string; + sortKey: number; + }>; + }, + options?: { skipInvalidation?: boolean }, + ): Promise> => { const changedAssortmentProductIds = await Promise.all( sortKeys.map(async ({ assortmentProductId, sortKey }) => { await AssortmentProducts.updateOne(generateDbFilterById(assortmentProductId), { @@ -258,3 +267,5 @@ export const configureAssortmentProductsModule = ({ }, }; }; + +export type AssortmentProductsModule = ReturnType; diff --git a/packages/core-assortments/src/module/configureAssortmentsModule.ts b/packages/core-assortments/src/module/configureAssortmentsModule.ts index ccffd5a78..87136f343 100644 --- a/packages/core-assortments/src/module/configureAssortmentsModule.ts +++ b/packages/core-assortments/src/module/configureAssortmentsModule.ts @@ -9,12 +9,11 @@ import { ModuleInput, } from '@unchainedshop/mongodb'; import { createLogger } from '@unchainedshop/logger'; -import { resolveAssortmentProductFromDatabase } from '../utils/breadcrumbs/resolveAssortmentProductFromDatabase.js'; -import { resolveAssortmentLinkFromDatabase } from '../utils/breadcrumbs/resolveAssortmentLinkFromDatabase.js'; import addMigrations from '../migrations/addMigrations.js'; import { Assortment, AssortmentLink, + AssortmentProduct, AssortmentQuery, AssortmentsCollection, InvalidateCacheFn, @@ -46,6 +45,14 @@ export interface AssortmentPathLink { parentIds: string[]; } +export type BreadcrumbAssortmentLinkFunction = ( + childAssortmentId: string, +) => Promise>; + +export type BreacrumbAssortmentProductFunction = ( + productId: string, +) => Promise>; + const logger = createLogger('unchained:core'); export type AssortmentsModule = { @@ -73,10 +80,16 @@ export type AssortmentsModule = { ignoreChildAssortments?: boolean; }) => Promise>; - breadcrumbs: (params: { - assortmentId?: string; - productId?: string; - }) => Promise }>>; + breadcrumbs: ( + params: { + assortmentId?: string; + productId?: string; + }, + resolvers: { + resolveAssortmentLinks: BreadcrumbAssortmentLinkFunction; + resolveAssortmentProducts: BreacrumbAssortmentProductFunction; + }, + ) => Promise }>>; // Mutations create: (doc: Assortment) => Promise; @@ -419,12 +432,18 @@ export const configureAssortmentsModule = async ({ return !!assortmentCount; }, - breadcrumbs: async (params) => { - const resolveAssortmentLink = resolveAssortmentLinkFromDatabase(AssortmentLinks); - const resolveAssortmentProducts = resolveAssortmentProductFromDatabase(AssortmentProducts); - + breadcrumbs: async ( + params, + { + resolveAssortmentLinks, + resolveAssortmentProducts, + }: { + resolveAssortmentLinks: BreadcrumbAssortmentLinkFunction; + resolveAssortmentProducts: BreacrumbAssortmentProductFunction; + }, + ) => { const buildBreadcrumbs = makeAssortmentBreadcrumbsBuilder({ - resolveAssortmentLink, + resolveAssortmentLinks, resolveAssortmentProducts, }); diff --git a/packages/core-assortments/src/utils/breadcrumbs/build-paths.ts b/packages/core-assortments/src/utils/breadcrumbs/build-paths.ts index a48fa97d9..9fb2fa6af 100644 --- a/packages/core-assortments/src/utils/breadcrumbs/build-paths.ts +++ b/packages/core-assortments/src/utils/breadcrumbs/build-paths.ts @@ -1,37 +1,46 @@ -const walkAssortmentLinks = (resolveAssortmentLink) => async (rootAssortmentId) => { - const walk = async (assortmentId, initialPaths: string[], childAssortmentId?: string) => { - const assortmentLink = await resolveAssortmentLink(assortmentId, childAssortmentId); - if (!assortmentLink) return initialPaths; +import { + BreacrumbAssortmentProductFunction, + BreadcrumbAssortmentLinkFunction, +} from '../../assortments-index.js'; - const subAsssortmentLinks = await Promise.all( - assortmentLink.parentIds.map(async (parentAssortmentId) => { - return walk(parentAssortmentId, initialPaths, assortmentId); - }), - ); +const walkAssortmentLinks = + (resolveAssortmentLinks: BreadcrumbAssortmentLinkFunction) => async (rootAssortmentId) => { + const walk = async (assortmentId) => { + const parentAssortmentLinks = await resolveAssortmentLinks(assortmentId); + if (!parentAssortmentLinks?.length) + return [ + [ + { + childAssortmentId: assortmentId, + parentAssortmentId: null, + }, + ], + ]; - if (subAsssortmentLinks.length > 0) { - return subAsssortmentLinks - .map((subAsssortmentLink) => { - return subAsssortmentLink.map((subSubLinks) => [ - ...subSubLinks, - assortmentLink, - ...initialPaths, - ]); - }) - .flat(); - } - return [[assortmentLink, ...initialPaths]]; + return await Promise.all( + parentAssortmentLinks.map(async (assortmentLink) => { + const upstream = await walk(assortmentLink.parentAssortmentId); + if (upstream.length) { + return [...upstream, assortmentLink].flat(); + } + return [assortmentLink]; + }), + ); + }; + // Recursively walk up the directed graph in reverse + return walk(rootAssortmentId); }; - // Recursively walk up the directed graph in reverse - return walk(rootAssortmentId, []); -}; export const walkUpFromProduct = async ({ resolveAssortmentProducts, - resolveAssortmentLink, + resolveAssortmentLinks, productId, +}: { + resolveAssortmentProducts: BreacrumbAssortmentProductFunction; + resolveAssortmentLinks: BreadcrumbAssortmentLinkFunction; + productId: string; }) => { - const pathResolver = walkAssortmentLinks(resolveAssortmentLink); + const pathResolver = walkAssortmentLinks(resolveAssortmentLinks); const assortmentProducts = await resolveAssortmentProducts(productId); return ( await Promise.all( @@ -47,12 +56,10 @@ export const walkUpFromProduct = async ({ ).flat(); }; -export const walkUpFromAssortment = async ({ resolveAssortmentLink, assortmentId }) => { - const pathResolver = walkAssortmentLinks(resolveAssortmentLink); +export const walkUpFromAssortment = async ({ resolveAssortmentLinks, assortmentId }) => { + const pathResolver = walkAssortmentLinks(resolveAssortmentLinks); const paths = await pathResolver(assortmentId); - return paths - .map((links) => ({ - links: links.slice(0, -1), - })) - .filter(({ links }) => links.length); + return paths.map((links) => ({ + links: links.slice(0, -1), + })); }; diff --git a/packages/core-assortments/src/utils/breadcrumbs/makeAssortmentBreadcrumbsBuilder.ts b/packages/core-assortments/src/utils/breadcrumbs/makeAssortmentBreadcrumbsBuilder.ts index 78e424756..b2a624895 100644 --- a/packages/core-assortments/src/utils/breadcrumbs/makeAssortmentBreadcrumbsBuilder.ts +++ b/packages/core-assortments/src/utils/breadcrumbs/makeAssortmentBreadcrumbsBuilder.ts @@ -1,20 +1,20 @@ import { walkUpFromAssortment, walkUpFromProduct } from './build-paths.js'; -export const buildBreadcrumbs = async (params) => { +export const walk = async (params) => { const { productId } = params; if (productId) return walkUpFromProduct(params); return walkUpFromAssortment(params); }; export const makeAssortmentBreadcrumbsBuilder = ({ - resolveAssortmentLink, + resolveAssortmentLinks, resolveAssortmentProducts, }) => { return async (params: { assortmentId?: string; productId?: string }) => - buildBreadcrumbs({ + walk({ assortmentId: params.assortmentId, productId: params.productId, - resolveAssortmentLink, + resolveAssortmentLinks, resolveAssortmentProducts, }); }; diff --git a/packages/core-assortments/src/utils/breadcrumbs/resolveAssortmentLinkFromDatabase.ts b/packages/core-assortments/src/utils/breadcrumbs/resolveAssortmentLinkFromDatabase.ts deleted file mode 100644 index 79b986bc0..000000000 --- a/packages/core-assortments/src/utils/breadcrumbs/resolveAssortmentLinkFromDatabase.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { mongodb } from '@unchainedshop/mongodb'; -import { AssortmentLink } from '../../db/AssortmentsCollection.js'; - -export function resolveAssortmentLinkFromDatabase( - AssortmentLinks: mongodb.Collection, - selector: mongodb.Filter = {}, -) { - return async (assortmentId: string, childAssortmentId: string) => { - const links = await AssortmentLinks.find( - { childAssortmentId: assortmentId, ...selector }, - { - projection: { _id: 1, childAssortmentId: 1, parentAssortmentId: 1 }, - sort: { sortKey: 1, parentAssortmentId: 1 }, - }, - ).toArray(); - - const parentIds = links.map((link) => link.parentAssortmentId); - - return { - assortmentId, - childAssortmentId, - parentIds, - }; - }; -} diff --git a/packages/core-assortments/src/utils/breadcrumbs/resolveAssortmentProductFromDatabase.ts b/packages/core-assortments/src/utils/breadcrumbs/resolveAssortmentProductFromDatabase.ts deleted file mode 100644 index 3abf2c7fc..000000000 --- a/packages/core-assortments/src/utils/breadcrumbs/resolveAssortmentProductFromDatabase.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { mongodb } from '@unchainedshop/mongodb'; -import { AssortmentProduct } from '../../db/AssortmentsCollection.js'; - -export function resolveAssortmentProductFromDatabase( - AssortmentProducts: mongodb.Collection, - selector: mongodb.Filter = {}, -) { - return async (productId: string) => { - const assortmentProducts = AssortmentProducts.find( - { productId, ...selector }, - { - projection: { _id: true, assortmentId: true, productId: true }, - sort: { sortKey: 1, productId: 1 }, - }, - ); - - return assortmentProducts.toArray(); - }; -}