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
This commit is contained in:
6
.changeset/eight-peas-return.md
Normal file
6
.changeset/eight-peas-return.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/types": patch
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
feat(medusa,types): product variant store endpoints
|
||||
@@ -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,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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[]
|
||||
}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
67
packages/medusa/src/api/store/product-variants/[id]/route.ts
Normal file
67
packages/medusa/src/api/store/product-variants/[id]/route.ts
Normal file
@@ -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<HttpTypes.StoreProductVariantParams> &
|
||||
AuthenticatedMedusaRequest<StoreProductVariantParamsType>
|
||||
|
||||
export const GET = async (
|
||||
req: StoreVariantRetrieveRequest,
|
||||
res: MedusaResponse<HttpTypes.StoreProductVariantResponse>
|
||||
) => {
|
||||
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 })
|
||||
}
|
||||
98
packages/medusa/src/api/store/product-variants/helpers.ts
Normal file
98
packages/medusa/src/api/store/product-variants/helpers.ts
Normal file
@@ -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 <T>(
|
||||
req: StoreRequestWithContext<T>,
|
||||
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<string, ItemTaxLineDTO[]>()
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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"]),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
66
packages/medusa/src/api/store/product-variants/route.ts
Normal file
66
packages/medusa/src/api/store/product-variants/route.ts
Normal file
@@ -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<HttpTypes.StoreProductVariantParams> &
|
||||
AuthenticatedMedusaRequest<HttpTypes.StoreProductVariantParams>
|
||||
|
||||
export const GET = async (
|
||||
req: StoreVariantListRequest,
|
||||
res: MedusaResponse<HttpTypes.StoreProductVariantListResponse>
|
||||
) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
56
packages/medusa/src/api/store/product-variants/validators.ts
Normal file
56
packages/medusa/src/api/store/product-variants/validators.ts
Normal file
@@ -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
|
||||
>
|
||||
@@ -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<string, unknown>
|
||||
> = MedusaStoreRequest<Body, QueryFields> & {
|
||||
taxContext: {
|
||||
taxLineContext?: TaxCalculationContext
|
||||
taxInclusivityContext?: {
|
||||
automaticTaxes: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
> = StoreRequestWithContext<Body, QueryFields>
|
||||
|
||||
export const refetchProduct = async (
|
||||
idOrFilter: string | object,
|
||||
|
||||
18
packages/medusa/src/api/store/types.ts
Normal file
18
packages/medusa/src/api/store/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { MedusaStoreRequest } from "@medusajs/framework/http"
|
||||
import {
|
||||
MedusaPricingContext,
|
||||
TaxCalculationContext,
|
||||
} from "@medusajs/framework/types"
|
||||
|
||||
export type StoreRequestWithContext<
|
||||
Body,
|
||||
QueryFields = Record<string, unknown>
|
||||
> = MedusaStoreRequest<Body, QueryFields> & {
|
||||
pricingContext?: MedusaPricingContext
|
||||
taxContext?: {
|
||||
taxLineContext?: TaxCalculationContext
|
||||
taxInclusivityContext?: {
|
||||
automaticTaxes: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_PRICE_FIELD_PATHS = ["variants.calculated_price"]
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<any>).taxContext = {
|
||||
;(req as unknown as StoreRequestWithContext<any>).taxContext = {
|
||||
taxLineContext: taxLinesContext,
|
||||
taxInclusivityContext: inclusivity,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user