From 25a20ca95f702dfb8a34fe9e4f3c565a47c2655e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:12:07 +0100 Subject: [PATCH] feat(medusa,types): product variant store endpoints (#13730) * wip(medusa): product variant store endpoints * chore: refactor types * chore: changesets * fix: address feedback 1 * feat: load images for variants by default * fix: use query.graph directly instead of refetchEntity * feat: enable cache for variants endpoint --- .changeset/eight-peas-return.md | 6 + .../store/product-variant.spec.ts | 390 ++++++++++++++++++ .../types/src/http/product/store/responses.ts | 16 +- packages/medusa/src/api/middlewares.ts | 2 + .../api/store/product-variants/[id]/route.ts | 67 +++ .../src/api/store/product-variants/helpers.ts | 98 +++++ .../api/store/product-variants/middlewares.ts | 85 ++++ .../store/product-variants/query-config.ts | 39 ++ .../src/api/store/product-variants/route.ts | 66 +++ .../api/store/product-variants/validators.ts | 56 +++ .../medusa/src/api/store/products/helpers.ts | 13 +- packages/medusa/src/api/store/types.ts | 18 + .../utils/middlewares/products/constants.ts | 1 + .../products/normalize-data-for-context.ts | 43 +- .../products/set-pricing-context.ts | 13 +- .../middlewares/products/set-tax-context.ts | 17 +- 16 files changed, 901 insertions(+), 29 deletions(-) create mode 100644 .changeset/eight-peas-return.md create mode 100644 integration-tests/http/__tests__/product-variant/store/product-variant.spec.ts create mode 100644 packages/medusa/src/api/store/product-variants/[id]/route.ts create mode 100644 packages/medusa/src/api/store/product-variants/helpers.ts create mode 100644 packages/medusa/src/api/store/product-variants/middlewares.ts create mode 100644 packages/medusa/src/api/store/product-variants/query-config.ts create mode 100644 packages/medusa/src/api/store/product-variants/route.ts create mode 100644 packages/medusa/src/api/store/product-variants/validators.ts create mode 100644 packages/medusa/src/api/store/types.ts create mode 100644 packages/medusa/src/api/utils/middlewares/products/constants.ts diff --git a/.changeset/eight-peas-return.md b/.changeset/eight-peas-return.md new file mode 100644 index 0000000000..9d425733e5 --- /dev/null +++ b/.changeset/eight-peas-return.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/medusa": patch +--- + +feat(medusa,types): product variant store endpoints diff --git a/integration-tests/http/__tests__/product-variant/store/product-variant.spec.ts b/integration-tests/http/__tests__/product-variant/store/product-variant.spec.ts new file mode 100644 index 0000000000..015206cecf --- /dev/null +++ b/integration-tests/http/__tests__/product-variant/store/product-variant.spec.ts @@ -0,0 +1,390 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { HttpTypes } from "@medusajs/framework/types" +import { IStoreModuleService } from "@medusajs/types" +import { ApiKeyType, Modules, ProductStatus } from "@medusajs/utils" +import { + adminHeaders, + createAdminUser, + generatePublishableKey, + generateStoreHeaders, +} from "../../../../helpers/create-admin-user" +import { getProductFixture } from "../../../../helpers/fixtures" + +jest.setTimeout(60000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, api, getContainer }) => { + let appContainer + let publishableKey + let storeHeaders + let store + let region + let shippingProfile + + const createProduct = async (payload: HttpTypes.AdminCreateProduct) => { + const response = await api.post( + "/admin/products?fields=*variants", + payload, + adminHeaders + ) + + return [response.data.product, response.data.product.variants || []] + } + + const createSalesChannel = async ( + data: HttpTypes.AdminCreateSalesChannel, + productIds: string[] = [] + ) => { + const response = await api.post( + "/admin/sales-channels", + data, + adminHeaders + ) + + const salesChannel = response.data.sales_channel + + if (productIds?.length) { + await api.post( + `/admin/sales-channels/${salesChannel.id}/products`, + { add: productIds }, + adminHeaders + ) + } + + return salesChannel + } + + beforeEach(async () => { + appContainer = getContainer() + publishableKey = await generatePublishableKey(appContainer) + storeHeaders = generateStoreHeaders({ publishableKey }) + + await createAdminUser(dbConnection, adminHeaders, appContainer) + + const storeModule: IStoreModuleService = appContainer.resolve( + Modules.STORE + ) + + const defaultStoreId = (await api.get("/admin/stores", adminHeaders)).data + .stores?.[0]?.id + + if (defaultStoreId) { + await storeModule.deleteStores(defaultStoreId) + } + + store = await storeModule.createStores({ + name: "Store", + supported_currencies: [ + { currency_code: "usd", is_default: true }, + { currency_code: "eur" }, + ], + }) + + region = ( + await api.post( + "/admin/regions", + { name: "Test Region", currency_code: "usd" }, + adminHeaders + ) + ).data.region + + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "default", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + }) + + describe("GET /store/product-variants", () => { + let product1 + let product2 + let variant1 + let variant2 + let salesChannel1 + let salesChannel2 + + beforeEach(async () => { + ;[product1, [variant1]] = await createProduct( + getProductFixture({ + title: "Variant product 1", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + }) + ) + ;[product2, [variant2]] = await createProduct( + getProductFixture({ + title: "Variant product 2", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + }) + ) + + salesChannel1 = await createSalesChannel( + { name: "sales channel one" }, + [product1.id] + ) + + salesChannel2 = await createSalesChannel( + { name: "sales channel two" }, + [product2.id] + ) + + await api.post( + `/admin/stores/${store.id}`, + { default_sales_channel_id: salesChannel1.id }, + adminHeaders + ) + }) + + it("returns variants associated with the publishable key sales channel", async () => { + await api.post( + `/admin/api-keys/${publishableKey.id}/sales-channels`, + { add: [salesChannel1.id] }, + adminHeaders + ) + + const response = await api.get("/store/product-variants", storeHeaders) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.variants).toEqual([ + expect.objectContaining({ + id: variant1.id, + product_id: product1.id, + }), + ]) + }) + + it("allows overriding the sales channel when it is within publishable key scope", async () => { + await api.post( + `/admin/api-keys/${publishableKey.id}/sales-channels`, + { add: [salesChannel1.id, salesChannel2.id] }, + adminHeaders + ) + + const response = await api.get( + `/store/product-variants?sales_channel_id[]=${salesChannel2.id}`, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.variants).toEqual([ + expect.objectContaining({ + id: variant2.id, + product_id: product2.id, + }), + ]) + }) + + it("throws when filtering by a sales channel outside publishable key scope", async () => { + await api.post( + `/admin/api-keys/${publishableKey.id}/sales-channels`, + { add: [salesChannel1.id] }, + adminHeaders + ) + + const error = await api + .get( + `/store/product-variants?sales_channel_id[]=${salesChannel2.id}`, + storeHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual( + "Requested sales channel is not part of the publishable key" + ) + }) + }) + + describe("GET /store/product-variants/:id", () => { + let product1 + let product2 + let variant1 + let variant2 + let salesChannel1 + let salesChannel2 + + beforeEach(async () => { + ;[product1, [variant1]] = await createProduct( + getProductFixture({ + title: "Variant product 1", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + }) + ) + ;[product2, [variant2]] = await createProduct( + getProductFixture({ + title: "Variant product 2", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + }) + ) + + salesChannel1 = await createSalesChannel( + { name: "sales channel one" }, + [product1.id] + ) + + salesChannel2 = await createSalesChannel( + { name: "sales channel two" }, + [product2.id] + ) + + await api.post( + `/admin/api-keys/${publishableKey.id}/sales-channels`, + { add: [salesChannel1.id] }, + adminHeaders + ) + }) + + it("retrieves a variant available to the publishable key", async () => { + const response = await api.get( + `/store/product-variants/${variant1.id}`, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.variant).toEqual( + expect.objectContaining({ + id: variant1.id, + product_id: product1.id, + }) + ) + }) + + it("returns 404 when the variant is not available in the publishable key scope", async () => { + const error = await api + .get(`/store/product-variants/${variant2.id}`, storeHeaders) + .catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data.message).toEqual( + `Product variant with id: ${variant2.id} was not found` + ) + }) + + it("returns 404 when the variant does not exist", async () => { + const error = await api + .get(`/store/product-variants/not-real`, storeHeaders) + .catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data.message).toEqual( + "Product variant with id: not-real was not found" + ) + }) + + it("returns calculated price data when requested", async () => { + const response = await api.get( + `/store/product-variants/${variant1.id}?region_id=${region.id}&fields=calculated_price`, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.variant).toEqual( + expect.objectContaining({ + id: variant1.id, + calculated_price: expect.objectContaining({ + calculated_amount: expect.any(Number), + currency_code: "usd", + }), + }) + ) + }) + }) + + describe("GET /store/product-variants inventory quantities", () => { + it("returns inventory quantity scoped to publishable key sales channel", async () => { + const container = getContainer() + const channelService = container.resolve("sales_channel") + const locationService = container.resolve("stock_location") + const inventoryService = container.resolve("inventory") + const productService = container.resolve("product") + const pubKeyService = container.resolve("api_key") + const linkService = container.resolve("remoteLink") + + const [channel] = await channelService.createSalesChannels([ + { name: "PK Sales Channel" }, + ]) + + const product = await productService.createProducts({ + status: ProductStatus.PUBLISHED, + title: "inventory product", + options: [{ title: "size", values: ["large"] }], + variants: [ + { + title: "inv variant", + options: { size: "large" }, + }, + ], + }) + + const [variant] = product.variants + + const [inventoryItem] = await inventoryService.createInventoryItems([ + { sku: "inv-sku" }, + ]) + + const [location] = await locationService.createStockLocations([ + { name: "Warehouse" }, + ]) + + await inventoryService.createInventoryLevels([ + { + location_id: location.id, + inventory_item_id: inventoryItem.id, + stocked_quantity: 10, + }, + ]) + + const [pk] = await pubKeyService.createApiKeys([ + { + title: "Variant PK", + type: ApiKeyType.PUBLISHABLE, + created_by: "test", + }, + ]) + + await linkService.create([ + { + product: { product_id: product.id }, + sales_channel: { sales_channel_id: channel.id }, + }, + { + sales_channel: { sales_channel_id: channel.id }, + stock_location: { stock_location_id: location.id }, + }, + { + product: { variant_id: variant.id }, + inventory: { inventory_item_id: inventoryItem.id }, + }, + { + api_key: { publishable_key_id: pk.id }, + sales_channel: { sales_channel_id: channel.id }, + }, + ]) + + const response = await api.get( + `/store/product-variants?fields=+inventory_quantity`, + { + headers: { + "x-publishable-api-key": pk.token, + }, + } + ) + + expect(response.status).toEqual(200) + expect(response.data.variants).toEqual([ + expect.objectContaining({ + id: variant.id, + inventory_quantity: 10, + }), + ]) + }) + }) + }, +}) diff --git a/packages/core/types/src/http/product/store/responses.ts b/packages/core/types/src/http/product/store/responses.ts index 0b10deea9e..d763f3443d 100644 --- a/packages/core/types/src/http/product/store/responses.ts +++ b/packages/core/types/src/http/product/store/responses.ts @@ -1,5 +1,5 @@ import { PaginatedResponse } from "../../common" -import { StoreProduct } from "../store" +import { StoreProduct, StoreProductVariant } from "../store" export interface StoreProductResponse { /** @@ -14,3 +14,17 @@ export type StoreProductListResponse = PaginatedResponse<{ */ products: StoreProduct[] }> + +export interface StoreProductVariantResponse { + /** + * The product variant's details. + */ + variant: StoreProductVariant +} + +export type StoreProductVariantListResponse = PaginatedResponse<{ + /** + * The list of product variants. + */ + variants: StoreProductVariant[] +}> diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index 5d8c59b0c4..d98a560304 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -58,6 +58,7 @@ import { storePaymentProvidersMiddlewares } from "./store/payment-providers/midd import { storeProductCategoryRoutesMiddlewares } from "./store/product-categories/middlewares" import { storeProductTagRoutesMiddlewares } from "./store/product-tags/middlewares" import { storeProductTypeRoutesMiddlewares } from "./store/product-types/middlewares" +import { storeProductVariantRoutesMiddlewares } from "./store/product-variants/middlewares" import { storeProductRoutesMiddlewares } from "./store/products/middlewares" import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares" import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middlewares" @@ -119,6 +120,7 @@ export default defineMiddlewares([ ...adminFulfillmentsRoutesMiddlewares, ...adminFulfillmentProvidersRoutesMiddlewares, ...storeProductRoutesMiddlewares, + ...storeProductVariantRoutesMiddlewares, ...storeReturnReasonRoutesMiddlewares, ...adminReturnReasonRoutesMiddlewares, ...adminClaimRoutesMiddlewares, diff --git a/packages/medusa/src/api/store/product-variants/[id]/route.ts b/packages/medusa/src/api/store/product-variants/[id]/route.ts new file mode 100644 index 0000000000..21188801d6 --- /dev/null +++ b/packages/medusa/src/api/store/product-variants/[id]/route.ts @@ -0,0 +1,67 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { HttpTypes, QueryContextType } from "@medusajs/framework/types" +import { + ContainerRegistrationKeys, + MedusaError, + QueryContext, +} from "@medusajs/framework/utils" +import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../../utils/middlewares" +import { StoreRequestWithContext } from "../../types" +import { wrapVariantsWithTaxPrices } from "../helpers" +import { StoreProductVariantParamsType } from "../validators" + +type StoreVariantRetrieveRequest = + StoreRequestWithContext & + AuthenticatedMedusaRequest + +export const GET = async ( + req: StoreVariantRetrieveRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const withInventoryQuantity = + req.queryConfig.fields.includes("inventory_quantity") + + if (withInventoryQuantity) { + req.queryConfig.fields = req.queryConfig.fields.filter( + (field) => field !== "inventory_quantity" + ) + } + + const context: QueryContextType = {} + + if (req.pricingContext) { + context["calculated_price"] = QueryContext(req.pricingContext) + } + + const { data: variants = [] } = await query.graph({ + entity: "variant", + filters: { + ...req.filterableFields, + id: req.params.id, + }, + fields: req.queryConfig.fields, + context, + }) + + const variant = variants[0] + + if (!variant) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product variant with id: ${req.params.id} was not found` + ) + } + + if (withInventoryQuantity) { + await wrapVariantsWithInventoryQuantityForSalesChannel(req, [variant]) + } + + await wrapVariantsWithTaxPrices(req, [variant]) + + res.json({ variant }) +} diff --git a/packages/medusa/src/api/store/product-variants/helpers.ts b/packages/medusa/src/api/store/product-variants/helpers.ts new file mode 100644 index 0000000000..5b796d8384 --- /dev/null +++ b/packages/medusa/src/api/store/product-variants/helpers.ts @@ -0,0 +1,98 @@ +import { + HttpTypes, + ItemTaxLineDTO, + TaxableItemDTO, +} from "@medusajs/framework/types" +import { calculateAmountsWithTax, Modules } from "@medusajs/framework/utils" +import { StoreRequestWithContext } from "../types" + +export const wrapVariantsWithTaxPrices = async ( + req: StoreRequestWithContext, + variants: HttpTypes.StoreProductVariant[] +) => { + if ( + !req.taxContext?.taxInclusivityContext || + !req.taxContext?.taxLineContext + ) { + return + } + + if (!variants?.length) { + return + } + + const items = variants + .map(asTaxItem) + .filter((item) => !!item) as TaxableItemDTO[] + + if (!items.length) { + return + } + + const taxService = req.scope.resolve(Modules.TAX) + + const taxLines = (await taxService.getTaxLines( + items, + req.taxContext.taxLineContext + )) as unknown as ItemTaxLineDTO[] + + const taxRatesMap = new Map() + + taxLines.forEach((taxLine) => { + if (!taxRatesMap.has(taxLine.line_item_id)) { + taxRatesMap.set(taxLine.line_item_id, []) + } + + taxRatesMap.get(taxLine.line_item_id)!.push(taxLine) + }) + + variants.forEach((variant) => { + if (!variant.calculated_price) { + return + } + + const taxRatesForVariant = taxRatesMap.get(variant.id) || [] + + const { priceWithTax, priceWithoutTax } = calculateAmountsWithTax({ + taxLines: taxRatesForVariant, + amount: variant.calculated_price.calculated_amount!, + includesTax: variant.calculated_price.is_calculated_price_tax_inclusive!, + }) + + variant.calculated_price.calculated_amount_with_tax = priceWithTax + variant.calculated_price.calculated_amount_without_tax = priceWithoutTax + + const { + priceWithTax: originalPriceWithTax, + priceWithoutTax: originalPriceWithoutTax, + } = calculateAmountsWithTax({ + taxLines: taxRatesForVariant, + amount: variant.calculated_price.original_amount!, + includesTax: variant.calculated_price.is_original_price_tax_inclusive!, + }) + + variant.calculated_price.original_amount_with_tax = originalPriceWithTax + variant.calculated_price.original_amount_without_tax = + originalPriceWithoutTax + }) +} + +const asTaxItem = (variant: HttpTypes.StoreProductVariant) => { + if (!variant.calculated_price) { + return + } + + const productId = variant.product_id ?? variant.product?.id + if (!productId) { + return + } + + return { + id: variant.id, + product_id: productId, + product_type_id: variant.product?.type_id ?? undefined, + quantity: 1, + unit_price: variant.calculated_price.calculated_amount, + currency_code: variant.calculated_price.currency_code, + } +} diff --git a/packages/medusa/src/api/store/product-variants/middlewares.ts b/packages/medusa/src/api/store/product-variants/middlewares.ts new file mode 100644 index 0000000000..f3df6078af --- /dev/null +++ b/packages/medusa/src/api/store/product-variants/middlewares.ts @@ -0,0 +1,85 @@ +import { validateAndTransformQuery } from "@medusajs/framework" +import { + applyDefaultFilters, + applyParamsAsFilters, + authenticate, + clearFiltersByKey, + maybeApplyLinkFilter, + MiddlewareRoute, +} from "@medusajs/framework/http" +import { ProductStatus } from "@medusajs/framework/utils" +import { + filterByValidSalesChannels, + normalizeDataForContext, + setPricingContext, + setTaxContext, +} from "../../utils/middlewares" +import * as QueryConfig from "./query-config" +import { + StoreProductVariantListParams, + StoreProductVariantParams, +} from "./validators" + +const pricingMiddlewares = [ + normalizeDataForContext({ priceFieldPaths: ["calculated_price"] }), + setPricingContext({ priceFieldPaths: ["calculated_price"] }), + setTaxContext({ priceFieldPaths: ["calculated_price"] }), +] + +export const storeProductVariantRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/store/product-variants", + middlewares: [ + authenticate("customer", ["session", "bearer"], { + allowUnauthenticated: true, + }), + validateAndTransformQuery( + StoreProductVariantListParams, + QueryConfig.listProductVariantConfig + ), + filterByValidSalesChannels(), + maybeApplyLinkFilter({ + entryPoint: "product_sales_channel", + resourceId: "product_id", + filterableField: "sales_channel_id", + filterByField: "product.id", + }), + applyDefaultFilters({ + product: { + status: ProductStatus.PUBLISHED, + }, + }), + ...pricingMiddlewares, + clearFiltersByKey(["region_id", "country_code", "province", "cart_id"]), + ], + }, + { + method: ["GET"], + matcher: "/store/product-variants/:id", + middlewares: [ + authenticate("customer", ["session", "bearer"], { + allowUnauthenticated: true, + }), + validateAndTransformQuery( + StoreProductVariantParams, + QueryConfig.retrieveProductVariantConfig + ), + applyParamsAsFilters({ id: "id" }), + filterByValidSalesChannels(), + maybeApplyLinkFilter({ + entryPoint: "product_sales_channel", + resourceId: "product_id", + filterableField: "sales_channel_id", + filterByField: "product.id", + }), + applyDefaultFilters({ + product: { + status: ProductStatus.PUBLISHED, + }, + }), + ...pricingMiddlewares, + clearFiltersByKey(["region_id", "country_code", "province", "cart_id"]), + ], + }, +] diff --git a/packages/medusa/src/api/store/product-variants/query-config.ts b/packages/medusa/src/api/store/product-variants/query-config.ts new file mode 100644 index 0000000000..564dbf0b5e --- /dev/null +++ b/packages/medusa/src/api/store/product-variants/query-config.ts @@ -0,0 +1,39 @@ +export const defaultStoreProductVariantFields = [ + "id", + "title", + "sku", + "barcode", + "ean", + "upc", + "allow_backorder", + "manage_inventory", + "variant_rank", + "product_id", + "thumbnail", + "hs_code", + "origin_country", + "mid_code", + "material", + "weight", + "length", + "height", + "width", + "created_at", + "updated_at", + "metadata", + "*options", + "*images", + "product.id", + "product.type_id", +] + +export const retrieveProductVariantConfig = { + defaults: defaultStoreProductVariantFields, + isList: false, +} + +export const listProductVariantConfig = { + ...retrieveProductVariantConfig, + defaultLimit: 20, + isList: true, +} diff --git a/packages/medusa/src/api/store/product-variants/route.ts b/packages/medusa/src/api/store/product-variants/route.ts new file mode 100644 index 0000000000..f63a520131 --- /dev/null +++ b/packages/medusa/src/api/store/product-variants/route.ts @@ -0,0 +1,66 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { HttpTypes, QueryContextType } from "@medusajs/framework/types" +import { + ContainerRegistrationKeys, + QueryContext, +} from "@medusajs/framework/utils" +import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../utils/middlewares" +import { StoreRequestWithContext } from "../types" +import { wrapVariantsWithTaxPrices } from "./helpers" + +type StoreVariantListRequest = + StoreRequestWithContext & + AuthenticatedMedusaRequest + +export const GET = async ( + req: StoreVariantListRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const withInventoryQuantity = + req.queryConfig.fields.includes("inventory_quantity") + + if (withInventoryQuantity) { + req.queryConfig.fields = req.queryConfig.fields.filter( + (field) => field !== "inventory_quantity" + ) + } + + const context: QueryContextType = {} + + if (req.pricingContext) { + context["calculated_price"] = QueryContext(req.pricingContext) + } + + const { data: variants = [], metadata } = await query.graph( + { + entity: "variant", + fields: req.queryConfig.fields, + filters: req.filterableFields, + pagination: req.queryConfig.pagination, + context, + }, + { + cache: { + enable: true, + }, + } + ) + + if (withInventoryQuantity) { + await wrapVariantsWithInventoryQuantityForSalesChannel(req, variants) + } + + await wrapVariantsWithTaxPrices(req, variants) + + res.json({ + variants, + count: metadata?.count ?? 0, + offset: metadata?.skip ?? 0, + limit: metadata?.take ?? 0, + }) +} diff --git a/packages/medusa/src/api/store/product-variants/validators.ts b/packages/medusa/src/api/store/product-variants/validators.ts new file mode 100644 index 0000000000..4ac119a977 --- /dev/null +++ b/packages/medusa/src/api/store/product-variants/validators.ts @@ -0,0 +1,56 @@ +import { z } from "zod" +import { + applyAndAndOrOperators, + booleanString, +} from "../../utils/common-validators" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../utils/validators" + +const StoreProductVariantContextFields = z.object({ + region_id: z.string().optional(), + country_code: z.string().optional(), + province: z.string().optional(), + cart_id: z.string().optional(), + sales_channel_id: z.union([z.string(), z.array(z.string())]).optional(), +}) + +const StoreProductVariantFilterFields = z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + sku: z.union([z.string(), z.array(z.string())]).optional(), + product_id: z.union([z.string(), z.array(z.string())]).optional(), + options: z + .object({ + value: z.string().optional(), + option_id: z.string().optional(), + }) + .optional(), + allow_backorder: booleanString().optional(), + manage_inventory: booleanString().optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), +}) + +export const StoreProductVariantParams = createSelectParams().merge( + StoreProductVariantContextFields +) + +export type StoreProductVariantParamsType = z.infer< + typeof StoreProductVariantParams +> + +export const StoreProductVariantListParams = createFindParams({ + offset: 0, + limit: 20, +}) + .merge(StoreProductVariantContextFields) + .merge(StoreProductVariantFilterFields) + .merge(applyAndAndOrOperators(StoreProductVariantFilterFields)) + +export type StoreProductVariantListParamsType = z.infer< + typeof StoreProductVariantListParams +> diff --git a/packages/medusa/src/api/store/products/helpers.ts b/packages/medusa/src/api/store/products/helpers.ts index f66f529ae2..8f991e5c5c 100644 --- a/packages/medusa/src/api/store/products/helpers.ts +++ b/packages/medusa/src/api/store/products/helpers.ts @@ -1,24 +1,17 @@ -import { MedusaStoreRequest, refetchEntity } from "@medusajs/framework/http" +import { refetchEntity } from "@medusajs/framework/http" import { HttpTypes, ItemTaxLineDTO, MedusaContainer, TaxableItemDTO, - TaxCalculationContext, } from "@medusajs/framework/types" import { calculateAmountsWithTax, Modules } from "@medusajs/framework/utils" +import { StoreRequestWithContext } from "../types" export type RequestWithContext< Body, QueryFields = Record -> = MedusaStoreRequest & { - taxContext: { - taxLineContext?: TaxCalculationContext - taxInclusivityContext?: { - automaticTaxes: boolean - } - } -} +> = StoreRequestWithContext export const refetchProduct = async ( idOrFilter: string | object, diff --git a/packages/medusa/src/api/store/types.ts b/packages/medusa/src/api/store/types.ts new file mode 100644 index 0000000000..6513491549 --- /dev/null +++ b/packages/medusa/src/api/store/types.ts @@ -0,0 +1,18 @@ +import { MedusaStoreRequest } from "@medusajs/framework/http" +import { + MedusaPricingContext, + TaxCalculationContext, +} from "@medusajs/framework/types" + +export type StoreRequestWithContext< + Body, + QueryFields = Record +> = MedusaStoreRequest & { + pricingContext?: MedusaPricingContext + taxContext?: { + taxLineContext?: TaxCalculationContext + taxInclusivityContext?: { + automaticTaxes: boolean + } + } +} diff --git a/packages/medusa/src/api/utils/middlewares/products/constants.ts b/packages/medusa/src/api/utils/middlewares/products/constants.ts new file mode 100644 index 0000000000..d39c2dccc2 --- /dev/null +++ b/packages/medusa/src/api/utils/middlewares/products/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_PRICE_FIELD_PATHS = ["variants.calculated_price"] diff --git a/packages/medusa/src/api/utils/middlewares/products/normalize-data-for-context.ts b/packages/medusa/src/api/utils/middlewares/products/normalize-data-for-context.ts index 18e1f2839e..f6efc29e9c 100644 --- a/packages/medusa/src/api/utils/middlewares/products/normalize-data-for-context.ts +++ b/packages/medusa/src/api/utils/middlewares/products/normalize-data-for-context.ts @@ -5,26 +5,45 @@ import { refetchEntities, refetchEntity, } from "@medusajs/framework/http" +import { DEFAULT_PRICE_FIELD_PATHS } from "./constants" + +type PricingContextOptions = { + priceFieldPaths?: string[] +} + +export function normalizeDataForContext(options: PricingContextOptions = {}) { + const { priceFieldPaths = DEFAULT_PRICE_FIELD_PATHS } = options -export function normalizeDataForContext() { return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => { // If the product pricing is not requested, we don't need region information - const calculatedPriceIndex = req.queryConfig.fields.findIndex((field) => - field.startsWith("variants.calculated_price") - ) - let withCalculatedPrice = false - if (calculatedPriceIndex !== -1) { - req.queryConfig.fields[calculatedPriceIndex] = - "variants.calculated_price.*" - withCalculatedPrice = true - } + + req.queryConfig.fields = req.queryConfig.fields.map((field) => { + for (const pricePath of priceFieldPaths) { + if (field === pricePath) { + withCalculatedPrice = true + return `${pricePath}.*` + } + + if (field.startsWith(`${pricePath}.`)) { + withCalculatedPrice = true + return field + } + } + + return field + }) // If the region is passed, we calculate the prices without requesting them. // TODO: This seems a bit messy, reconsider if we want to keep this logic. if (!withCalculatedPrice && req.filterableFields.region_id) { - req.queryConfig.fields.push("variants.calculated_price.*") - withCalculatedPrice = true + for (const pricePath of priceFieldPaths) { + const wildcardField = `${pricePath}.*` + if (!req.queryConfig.fields.includes(wildcardField)) { + req.queryConfig.fields.push(wildcardField) + } + } + withCalculatedPrice = priceFieldPaths.length > 0 } if (!withCalculatedPrice) { diff --git a/packages/medusa/src/api/utils/middlewares/products/set-pricing-context.ts b/packages/medusa/src/api/utils/middlewares/products/set-pricing-context.ts index 1ee14c3c7d..cc7f41c274 100644 --- a/packages/medusa/src/api/utils/middlewares/products/set-pricing-context.ts +++ b/packages/medusa/src/api/utils/middlewares/products/set-pricing-context.ts @@ -6,11 +6,20 @@ import { import { MedusaPricingContext } from "@medusajs/framework/types" import { MedusaError } from "@medusajs/framework/utils" import { NextFunction } from "express" +import { DEFAULT_PRICE_FIELD_PATHS } from "./constants" + +type PricingContextOptions = { + priceFieldPaths?: string[] +} + +export function setPricingContext(options: PricingContextOptions = {}) { + const { priceFieldPaths = DEFAULT_PRICE_FIELD_PATHS } = options -export function setPricingContext() { return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => { const withCalculatedPrice = req.queryConfig.fields.some((field) => - field.startsWith("variants.calculated_price") + priceFieldPaths.some( + (pricePath) => field === pricePath || field.startsWith(`${pricePath}.`) + ) ) if (!withCalculatedPrice) { return next() diff --git a/packages/medusa/src/api/utils/middlewares/products/set-tax-context.ts b/packages/medusa/src/api/utils/middlewares/products/set-tax-context.ts index 693ee54339..fd5620e5b8 100644 --- a/packages/medusa/src/api/utils/middlewares/products/set-tax-context.ts +++ b/packages/medusa/src/api/utils/middlewares/products/set-tax-context.ts @@ -6,12 +6,21 @@ import { refetchEntity, } from "@medusajs/framework/http" import { MedusaError } from "@medusajs/framework/utils" -import { RequestWithContext } from "../../../store/products/helpers" +import { StoreRequestWithContext } from "../../../store/types" +import { DEFAULT_PRICE_FIELD_PATHS } from "./constants" + +type TaxContextOptions = { + priceFieldPaths?: string[] +} + +export function setTaxContext(options: TaxContextOptions = {}) { + const { priceFieldPaths = DEFAULT_PRICE_FIELD_PATHS } = options -export function setTaxContext() { return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => { const withCalculatedPrice = req.queryConfig.fields.some((field) => - field.startsWith("variants.calculated_price") + priceFieldPaths.some( + (pricePath) => field === pricePath || field.startsWith(`${pricePath}.`) + ) ) if (!withCalculatedPrice) { return next() @@ -26,7 +35,7 @@ export function setTaxContext() { const taxLinesContext = await getTaxLinesContext(req) // TODO: Allow passing a context typings param to AuthenticatedMedusaRequest - ;(req as unknown as RequestWithContext).taxContext = { + ;(req as unknown as StoreRequestWithContext).taxContext = { taxLineContext: taxLinesContext, taxInclusivityContext: inclusivity, }