From 3661eab87a8b8883734dbc52e2720c7487c616ab Mon Sep 17 00:00:00 2001 From: Pascal Kaufmann Date: Fri, 29 May 2026 11:22:16 +0200 Subject: [PATCH] feat: add cartLoader to batch User.cart resolution The cart query in core-orders used a plain findOne with no batching, so resolving carts for multiple users in one request (e.g. admin `users { cart }`) issued N queries. - Add a batched `findCarts({ userIds })` query plus a shared `buildCartSelector` helper in configureOrdersModule-queries (cart now reuses it). - Add `cartLoader` keyed by `{ userId, countryCode, orderNumber }`, batching all keys into one `findCarts` query and resolving each via most-recent-first match, mirroring `modules.orders.cart` semantics. - Use the loader in the `User.cart` resolver (covers me/user/users). Mutations, MCP tools and core services keep the direct module call. --- packages/api/src/loaders/cartLoader.ts | 25 ++++++++ packages/api/src/loaders/index.ts | 3 + packages/api/src/resolvers/type/user-types.ts | 4 +- .../module/configureOrdersModule-queries.ts | 64 ++++++++++++++++--- 4 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 packages/api/src/loaders/cartLoader.ts diff --git a/packages/api/src/loaders/cartLoader.ts b/packages/api/src/loaders/cartLoader.ts new file mode 100644 index 000000000..5bd3b4032 --- /dev/null +++ b/packages/api/src/loaders/cartLoader.ts @@ -0,0 +1,25 @@ +import type { UnchainedCore } from '@unchainedshop/core'; +import type { Order } from '@unchainedshop/core-orders'; +import DataLoader from 'dataloader'; + +export default (unchainedAPI: UnchainedCore) => + new DataLoader<{ userId: string; countryCode?: string; orderNumber?: string }, Order | null>( + async (queries) => { + const userIds = [...new Set(queries.map((q) => q.userId).filter(Boolean))]; + + const carts = await unchainedAPI.modules.orders.findCarts({ userIds }); + + // findCarts returns carts sorted by `updated` descending, so the first + // match per query key is the most recently updated cart, mirroring the + // semantics of modules.orders.cart. + return queries.map( + (q) => + carts.find( + (cart) => + cart.userId === q.userId && + (!q.countryCode || cart.countryCode === q.countryCode) && + (!q.orderNumber || cart.orderNumber === q.orderNumber), + ) || null, + ); + }, + ); diff --git a/packages/api/src/loaders/index.ts b/packages/api/src/loaders/index.ts index 1c43b5d12..37ee92e89 100644 --- a/packages/api/src/loaders/index.ts +++ b/packages/api/src/loaders/index.ts @@ -27,6 +27,7 @@ import deliveryProviderLoader from './deliveryProviderLoader.ts'; import paymentProviderLoader from './paymentProviderLoader.ts'; import warehousingProviderLoader from './warehousingProviderLoader.ts'; import orderLoader from './orderLoader.ts'; +import cartLoader from './cartLoader.ts'; import quotationLoader from './quotationLoader.ts'; import tokenExportStatusLoader from './tokenExportStatusLoader.ts'; @@ -72,6 +73,8 @@ const loaders = (unchainedAPI: UnchainedCore) => { orderLoader: orderLoader(unchainedAPI), + cartLoader: cartLoader(unchainedAPI), + quotationLoader: quotationLoader(unchainedAPI), tokenExportStatusLoader: tokenExportStatusLoader(unchainedAPI), diff --git a/packages/api/src/resolvers/type/user-types.ts b/packages/api/src/resolvers/type/user-types.ts index d622f0410..c2fe88839 100755 --- a/packages/api/src/resolvers/type/user-types.ts +++ b/packages/api/src/resolvers/type/user-types.ts @@ -168,9 +168,9 @@ export const User: UserHelperTypes = { }, async cart(user, params, context) { - const { modules, countryCode } = context; + const { loaders, countryCode } = context; await checkAction(context, viewUserOrders, [user, params]); - return modules.orders.cart({ + return loaders.cartLoader.load({ countryCode, orderNumber: params.orderNumber, userId: user._id, diff --git a/packages/core-orders/src/module/configureOrdersModule-queries.ts b/packages/core-orders/src/module/configureOrdersModule-queries.ts index 62397da9a..93245c536 100644 --- a/packages/core-orders/src/module/configureOrdersModule-queries.ts +++ b/packages/core-orders/src/module/configureOrdersModule-queries.ts @@ -35,6 +35,36 @@ export interface DateRange { export type StatisticsDateField = 'created' | 'ordered' | 'rejected' | 'confirmed' | 'fulfilled'; +function buildCartSelector({ + countryCode, + orderNumber, + userId, + userIds, +}: { + countryCode?: string; + orderNumber?: string; + userId?: string; + userIds?: string[]; +}): mongodb.Filter { + const selector: mongodb.Filter = { + status: { $eq: null }, + }; + + if (userIds) { + selector.userId = { $in: userIds }; + } else if (userId) { + selector.userId = userId; + } + if (countryCode) { + selector.countryCode = countryCode; + } + if (orderNumber) { + selector.orderNumber = orderNumber; + } + + return selector; +} + function buildDateMatch(dateField: string, dateRange?: DateRange) { if (!dateRange?.start && !dateRange?.end) return { [dateField]: { $exists: true } }; @@ -70,16 +100,7 @@ export const configureOrdersModuleQueries = ({ Orders }: { Orders: mongodb.Colle orderNumber?: string; userId: string; }) => { - const selector: mongodb.Filter = { - countryCode, - status: { $eq: null }, - userId, - }; - - if (orderNumber) { - selector.orderNumber = orderNumber; - } - + const selector = buildCartSelector({ countryCode, orderNumber, userId }); const options: mongodb.FindOptions = { sort: { updated: -1, @@ -88,6 +109,29 @@ export const configureOrdersModuleQueries = ({ Orders }: { Orders: mongodb.Colle return Orders.findOne(selector, options); }, + // Batched variant of `cart`, used by the API cartLoader. Returns the open + // carts (status === null) for the given users, most recently updated first. + findCarts: async ( + { + countryCode, + orderNumber, + userId, + userIds, + }: { + countryCode?: string; + orderNumber?: string; + userId?: string; + userIds?: string[]; + }, + options?: mongodb.FindOptions, + ): Promise => { + const selector = buildCartSelector({ countryCode, orderNumber, userId, userIds }); + return Orders.find(selector, { + sort: { updated: -1 }, + ...options, + }).toArray(); + }, + count: async (query: OrderQuery): Promise => { const orderCount = await Orders.countDocuments(buildFindSelector(query)); return orderCount;