diff --git a/.changeset/khaki-mice-peel.md b/.changeset/khaki-mice-peel.md new file mode 100644 index 0000000000..8db1f31449 --- /dev/null +++ b/.changeset/khaki-mice-peel.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(medusa,types): added store apis for products diff --git a/integration-tests/modules/__tests__/product/store/index.spec.ts b/integration-tests/modules/__tests__/product/store/index.spec.ts new file mode 100644 index 0000000000..b989cd4ed7 --- /dev/null +++ b/integration-tests/modules/__tests__/product/store/index.spec.ts @@ -0,0 +1,408 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ApiKeyType, ProductStatus } from "@medusajs/utils" +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { createAdminUser } from "../../../../helpers/create-admin-user" +import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } } + +medusaIntegrationTestRunner({ + env, + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Store: Products API", () => { + let appContainer + let product + let product2 + let product3 + let product4 + let variant + let variant2 + let variant3 + let variant4 + + const createProducts = async (data) => { + const response = await api.post( + "/admin/products?fields=*variants", + data, + adminHeaders + ) + + return [response.data.product, response.data.product.variants || []] + } + + const createSalesChannel = async (data, productIds) => { + const response = await api.post( + "/admin/sales-channels", + data, + adminHeaders + ) + + const salesChannel = response.data.sales_channel + + await api.post( + `/admin/sales-channels/${salesChannel.id}/products`, + { add: productIds }, + adminHeaders + ) + + return salesChannel + } + + beforeAll(async () => { + appContainer = getContainer() + }) + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders, appContainer) + await createDefaultRuleTypes(appContainer) + }) + + describe("GET /store/products", () => { + beforeEach(async () => { + ;[product, [variant]] = await createProducts({ + title: "test product 1", + status: ProductStatus.PUBLISHED, + variants: [ + { + title: "test variant 1", + prices: [{ amount: 3000, currency_code: "usd" }], + }, + ], + }) + ;[product2, [variant2]] = await createProducts({ + title: "test product 2 uniquely", + status: ProductStatus.PUBLISHED, + variants: [{ title: "test variant 2", prices: [] }], + }) + ;[product3, [variant3]] = await createProducts({ + title: "product not in price list", + status: ProductStatus.PUBLISHED, + variants: [{ title: "test variant 3", prices: [] }], + }) + ;[product4, [variant4]] = await createProducts({ + title: "draft product", + status: ProductStatus.DRAFT, + variants: [{ title: "test variant 4", prices: [] }], + }) + + const defaultSalesChannel = await createSalesChannel( + { name: "default sales channel" }, + [product.id, product2.id, product3.id, product4.id] + ) + + const service = appContainer.resolve(ModuleRegistrationName.STORE) + const [store] = await service.list() + + if (store) { + await service.delete(store.id) + } + + await service.create({ + supported_currency_codes: ["usd", "dkk"], + default_currency_code: "usd", + default_sales_channel_id: defaultSalesChannel.id, + }) + }) + + it("should list all published products", async () => { + let response = await api.get(`/store/products`) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product.id, + }), + expect.objectContaining({ + id: product2.id, + }), + expect.objectContaining({ + id: product3.id, + }), + ]) + ) + + response = await api.get(`/store/products?q=uniquely`) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: product2.id, + }), + ]) + }) + + it("should list all products for a sales channel", async () => { + const salesChannel = await createSalesChannel( + { name: "sales channel test" }, + [product.id] + ) + + let response = await api.get( + `/store/products?sales_channel_id[]=${salesChannel.id}` + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: product.id, + }), + ]) + }) + + describe("with publishable keys", () => { + let salesChannel1 + let salesChannel2 + let publishableKey1 + + beforeEach(async () => { + salesChannel1 = await createSalesChannel( + { name: "sales channel test" }, + [product.id] + ) + + salesChannel2 = await createSalesChannel( + { name: "sales channel test 2" }, + [product2.id] + ) + + const api1Res = await api.post( + `/admin/api-keys`, + { title: "Test publishable KEY", type: ApiKeyType.PUBLISHABLE }, + adminHeaders + ) + + publishableKey1 = api1Res.data.api_key + + await api.post( + `/admin/api-keys/${publishableKey1.id}/sales-channels`, + { add: [salesChannel1.id] }, + adminHeaders + ) + }) + + it("should list all products for a sales channel", async () => { + let response = await api.get( + `/store/products?sales_channel_id[]=${salesChannel1.id}`, + { headers: { "x-publishable-api-key": publishableKey1.token } } + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: product.id, + }), + ]) + }) + + it("should throw error when publishable key is invalid", async () => { + let error = await api + .get(`/store/products?sales_channel_id[]=does-not-exist`, { + headers: { "x-publishable-api-key": "does-not-exist" }, + }) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + message: `Publishable API key not found`, + type: "invalid_data", + }) + }) + + it("should throw error when sales channel does not exist", async () => { + let error = await api + .get(`/store/products?sales_channel_id[]=does-not-exist`, { + headers: { "x-publishable-api-key": publishableKey1.token }, + }) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + message: `Invalid sales channel filters provided - does-not-exist`, + type: "invalid_data", + }) + }) + + it("should throw error when sales channel not in publishable key", async () => { + let error = await api + .get(`/store/products?sales_channel_id[]=${salesChannel2.id}`, { + headers: { "x-publishable-api-key": publishableKey1.token }, + }) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + message: `Invalid sales channel filters provided - ${salesChannel2.id}`, + type: "invalid_data", + }) + }) + }) + + it("should throw error when calculating prices without context", async () => { + let error = await api + .get(`/store/products?fields=*variants.calculated_price`) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + message: + "Pricing parameters (currency_code or region_id) are required to calculate prices", + type: "invalid_data", + }) + }) + + it("should list products with prices when context is present", async () => { + let response = await api.get( + `/store/products?fields=*variants.calculated_price¤cy_code=usd` + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product.id, + variants: [ + expect.objectContaining({ + calculated_price: { + id: expect.any(String), + is_calculated_price_price_list: false, + calculated_amount: 3000, + is_original_price_price_list: false, + original_amount: 3000, + currency_code: "usd", + calculated_price: { + id: expect.any(String), + price_list_id: null, + price_list_type: null, + min_quantity: null, + max_quantity: null, + }, + original_price: { + id: expect.any(String), + price_list_id: null, + price_list_type: null, + min_quantity: null, + max_quantity: null, + }, + }, + }), + ], + }), + ]) + ) + }) + }) + + describe("GET /store/products/:id", () => { + beforeEach(async () => { + ;[product, [variant]] = await createProducts({ + title: "test product 1", + status: ProductStatus.PUBLISHED, + variants: [ + { + title: "test variant 1", + prices: [{ amount: 3000, currency_code: "usd" }], + }, + ], + }) + + const defaultSalesChannel = await createSalesChannel( + { name: "default sales channel" }, + [product.id] + ) + + const service = appContainer.resolve(ModuleRegistrationName.STORE) + const [store] = await service.list() + + if (store) { + await service.delete(store.id) + } + + await service.create({ + supported_currency_codes: ["usd", "dkk"], + default_currency_code: "usd", + default_sales_channel_id: defaultSalesChannel.id, + }) + }) + + it("should retrieve product successfully", async () => { + let response = await api.get(`/store/products/${product.id}`) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: product.id, + variants: [ + expect.objectContaining({ + id: expect.any(String), + }), + ], + }) + ) + }) + + it("should throw error when calculating prices without context", async () => { + let error = await api + .get( + `/store/products/${product.id}?fields=*variants.calculated_price` + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + message: + "Pricing parameters (currency_code or region_id) are required to calculate prices", + type: "invalid_data", + }) + }) + + it("should get product with prices when context is present", async () => { + let response = await api.get( + `/store/products/${product.id}?fields=*variants.calculated_price¤cy_code=usd` + ) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: product.id, + variants: [ + expect.objectContaining({ + calculated_price: { + id: expect.any(String), + is_calculated_price_price_list: false, + calculated_amount: 3000, + is_original_price_price_list: false, + original_amount: 3000, + currency_code: "usd", + calculated_price: { + id: expect.any(String), + price_list_id: null, + price_list_type: null, + min_quantity: null, + max_quantity: null, + }, + original_price: { + id: expect.any(String), + price_list_id: null, + price_list_type: null, + min_quantity: null, + max_quantity: null, + }, + }, + }), + ], + }) + ) + }) + }) + }) + }, +}) diff --git a/packages/medusa/src/api-v2/admin/products/validators.ts b/packages/medusa/src/api-v2/admin/products/validators.ts index 2b55d5f1c1..4310de1c02 100644 --- a/packages/medusa/src/api-v2/admin/products/validators.ts +++ b/packages/medusa/src/api-v2/admin/products/validators.ts @@ -1,6 +1,6 @@ import { ProductStatus } from "@medusajs/utils" import { z } from "zod" -import { optionalBooleanMapper } from "../../../utils/validators/is-boolean" +import { GetProductsParams } from "../../utils/common-validators" import { createFindParams, createOperatorMap, @@ -13,50 +13,6 @@ export const AdminGetProductParams = createSelectParams() export const AdminGetProductVariantParams = createSelectParams() export const AdminGetProductOptionParams = createSelectParams() -export type AdminGetProductsParamsType = z.infer -export const AdminGetProductsParams = createFindParams({ - offset: 0, - limit: 50, -}).merge( - z.object({ - q: z.string().optional(), - id: z.union([z.string(), z.array(z.string())]).optional(), - status: statusEnum.array().optional(), - title: z.string().optional(), - handle: z.string().optional(), - is_giftcard: z.preprocess( - (val: any) => optionalBooleanMapper.get(val?.toLowerCase()), - z.boolean().optional() - ), - category_id: z.string().array().optional(), - price_list_id: z.string().array().optional(), - sales_channel_id: z.string().array().optional(), - collection_id: z.string().array().optional(), - tags: z.string().array().optional(), - type_id: z.string().array().optional(), - // TODO: Replace this with AdminGetProductVariantsParams when its available - variants: z.record(z.unknown()).optional(), - created_at: createOperatorMap().optional(), - updated_at: createOperatorMap().optional(), - deleted_at: createOperatorMap().optional(), - $and: z.lazy(() => AdminGetProductsParams.array()).optional(), - $or: z.lazy(() => AdminGetProductsParams.array()).optional(), - }) -) -// TODO: These were part of the products find query, add them once supported -// @IsString() -// @IsOptional() -// discount_condition_id?: string - -// @IsArray() -// @IsOptional() -// category_id?: string[] - -// @IsBoolean() -// @IsOptional() -// @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase())) -// include_category_children?: boolean - export type AdminGetProductVariantsParamsType = z.infer< typeof AdminGetProductVariantsParams > @@ -77,6 +33,21 @@ export const AdminGetProductVariantsParams = createFindParams({ }) ) +export type AdminGetProductsParamsType = z.infer +export const AdminGetProductsParams = createFindParams({ + offset: 0, + limit: 50, +}).merge( + z + .object({ + variants: AdminGetProductVariantsParams.optional(), + price_list_id: z.string().array().optional(), + $and: z.lazy(() => AdminGetProductsParams.array()).optional(), + $or: z.lazy(() => AdminGetProductsParams.array()).optional(), + }) + .merge(GetProductsParams) +) + export type AdminGetProductOptionsParamsType = z.infer< typeof AdminGetProductOptionsParams > diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index 3d3a0a120c..d277b8c850 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -36,6 +36,7 @@ import { hooksRoutesMiddlewares } from "./hooks/middlewares" import { storeCartRoutesMiddlewares } from "./store/carts/middlewares" import { storeCurrencyRoutesMiddlewares } from "./store/currencies/middlewares" import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares" +import { storeProductRoutesMiddlewares } from "./store/products/middlewares" import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares" export const config: MiddlewaresConfig = { @@ -80,5 +81,6 @@ export const config: MiddlewaresConfig = { ...adminShippingProfilesMiddlewares, ...adminFulfillmentsRoutesMiddlewares, ...adminFulfillmentProvidersRoutesMiddlewares, + ...storeProductRoutesMiddlewares, ], } diff --git a/packages/medusa/src/api-v2/store/products/[id]/route.ts b/packages/medusa/src/api-v2/store/products/[id]/route.ts new file mode 100644 index 0000000000..d3cc6044f2 --- /dev/null +++ b/packages/medusa/src/api-v2/store/products/[id]/route.ts @@ -0,0 +1,26 @@ +import { isPresent } from "@medusajs/utils" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" +import { refetchProduct } from "../helpers" +import { StoreGetProductsParamsType } from "../validators" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const context = isPresent(req.pricingContext) + ? { + "variants.calculated_price": { context: req.pricingContext }, + } + : undefined + + const product = await refetchProduct( + { + id: req.params.id, + context, + }, + req.scope, + req.remoteQueryConfig.fields + ) + + res.json({ product }) +} diff --git a/packages/medusa/src/api-v2/store/products/helpers.ts b/packages/medusa/src/api-v2/store/products/helpers.ts new file mode 100644 index 0000000000..6c796de959 --- /dev/null +++ b/packages/medusa/src/api-v2/store/products/helpers.ts @@ -0,0 +1,34 @@ +import { MedusaContainer } from "@medusajs/types" +import { isPresent } from "@medusajs/utils" +import { refetchEntity } from "../../utils/refetch-entity" +import { StoreGetProductsParamsType } from "./validators" + +// For category filters, we only allow showcasing public and active categories +// TODO: This should ideally be done in the middleware, write a generic filter to conditionally +// map these values or normalize the filters to the ones expected by remote query +export function wrapWithCategoryFilters(filters: StoreGetProductsParamsType) { + const categoriesFilter = isPresent(filters.category_id) + ? { + categories: { + ...filters.category_id, + is_internal: false, + is_active: true, + }, + } + : {} + + delete filters.category_id + + return { + ...filters, + ...categoriesFilter, + } +} + +export const refetchProduct = async ( + idOrFilter: string | object, + scope: MedusaContainer, + fields: string[] +) => { + return await refetchEntity("product", idOrFilter, scope, fields) +} diff --git a/packages/medusa/src/api-v2/store/products/middlewares.ts b/packages/medusa/src/api-v2/store/products/middlewares.ts new file mode 100644 index 0000000000..6d755b47d0 --- /dev/null +++ b/packages/medusa/src/api-v2/store/products/middlewares.ts @@ -0,0 +1,67 @@ +import { ProductStatus } from "@medusajs/utils" +import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" +import { authenticate } from "../../../utils/authenticate-middleware" +import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter" +import { + applyDefaultFilters, + filterByValidSalesChannels, + setPricingContext, +} from "../../utils/middlewares" +import { validateAndTransformQuery } from "../../utils/validate-query" +import * as QueryConfig from "./query-config" +import { + StoreGetProductsParams, + StoreGetProductsParamsType, +} from "./validators" + +export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: "ALL", + matcher: "/store/products*", + middlewares: [ + authenticate("store", ["session", "bearer"], { + allowUnauthenticated: true, + }), + ], + }, + { + method: ["GET"], + matcher: "/store/products", + middlewares: [ + validateAndTransformQuery( + StoreGetProductsParams, + QueryConfig.listProductQueryConfig + ), + filterByValidSalesChannels(), + maybeApplyLinkFilter({ + entryPoint: "product_sales_channel", + resourceId: "product_id", + filterableField: "sales_channel_id", + }), + applyDefaultFilters({ + status: ProductStatus.PUBLISHED, + }), + setPricingContext(), + ], + }, + { + method: ["GET"], + matcher: "/store/products/:id", + middlewares: [ + validateAndTransformQuery( + StoreGetProductsParams, + QueryConfig.retrieveProductQueryConfig + ), + filterByValidSalesChannels(), + maybeApplyLinkFilter({ + entryPoint: "product_sales_channel", + resourceId: "product_id", + filterableField: "sales_channel_id", + }), + applyDefaultFilters({ + status: ProductStatus.PUBLISHED, + }), + setPricingContext(), + ], + }, +] diff --git a/packages/medusa/src/api-v2/store/products/query-config.ts b/packages/medusa/src/api-v2/store/products/query-config.ts new file mode 100644 index 0000000000..72e06df48a --- /dev/null +++ b/packages/medusa/src/api-v2/store/products/query-config.ts @@ -0,0 +1,41 @@ +export const defaultStoreProductFields = [ + "id", + "title", + "subtitle", + "description", + "handle", + "is_giftcard", + "discountable", + "thumbnail", + "collection_id", + "type_id", + "weight", + "length", + "height", + "width", + "hs_code", + "origin_country", + "mid_code", + "material", + "created_at", + "updated_at", + "*type", + "*collection", + "*options", + "*options.values", + "*tags", + "*images", + "*variants", + "*variants.options", +] + +export const retrieveProductQueryConfig = { + defaults: defaultStoreProductFields, + isList: false, +} + +export const listProductQueryConfig = { + ...retrieveProductQueryConfig, + defaultLimit: 50, + isList: true, +} diff --git a/packages/medusa/src/api-v2/store/products/route.ts b/packages/medusa/src/api-v2/store/products/route.ts new file mode 100644 index 0000000000..353642caba --- /dev/null +++ b/packages/medusa/src/api-v2/store/products/route.ts @@ -0,0 +1,39 @@ +import { + ContainerRegistrationKeys, + isPresent, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { MedusaRequest, MedusaResponse } from "../../../types/routing" +import { wrapWithCategoryFilters } from "./helpers" +import { StoreGetProductsParamsType } from "./validators" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + const context = isPresent(req.pricingContext) + ? { + "variants.calculated_price": { context: req.pricingContext }, + } + : undefined + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "product", + variables: { + filters: wrapWithCategoryFilters(req.filterableFields), + ...context, + ...req.remoteQueryConfig.pagination, + }, + fields: req.remoteQueryConfig.fields, + }) + + const { rows: products, metadata } = await remoteQuery(queryObject) + + res.json({ + products, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} diff --git a/packages/medusa/src/api-v2/store/products/validators.ts b/packages/medusa/src/api-v2/store/products/validators.ts new file mode 100644 index 0000000000..bcf9c1f1c8 --- /dev/null +++ b/packages/medusa/src/api-v2/store/products/validators.ts @@ -0,0 +1,48 @@ +import { z } from "zod" +import { + GetProductsParams, + ProductStatusEnum, +} from "../../utils/common-validators" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../utils/validators" + +export const StoreGetProductParams = createSelectParams() + +export type StoreGetProductVariantsParamsType = z.infer< + typeof StoreGetProductVariantsParams +> +export const StoreGetProductVariantsParams = createFindParams({ + offset: 0, + limit: 50, +}).merge( + z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + status: ProductStatusEnum.array().optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), + $and: z.lazy(() => StoreGetProductsParams.array()).optional(), + $or: z.lazy(() => StoreGetProductsParams.array()).optional(), + }) +) + +export type StoreGetProductsParamsType = z.infer +export const StoreGetProductsParams = createFindParams({ + offset: 0, + limit: 50, +}).merge( + z + .object({ + region_id: z.string().optional(), + currency_code: z.string().optional(), + variants: StoreGetProductVariantsParams.optional(), + $and: z.lazy(() => StoreGetProductsParams.array()).optional(), + $or: z.lazy(() => StoreGetProductsParams.array()).optional(), + }) + .merge(GetProductsParams) + .strict() +) diff --git a/packages/medusa/src/api-v2/utils/common-validators.ts b/packages/medusa/src/api-v2/utils/common-validators/common.ts similarity index 74% rename from packages/medusa/src/api-v2/utils/common-validators.ts rename to packages/medusa/src/api-v2/utils/common-validators/common.ts index ff3414a15b..7018683d2d 100644 --- a/packages/medusa/src/api-v2/utils/common-validators.ts +++ b/packages/medusa/src/api-v2/utils/common-validators/common.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { optionalBooleanMapper } from "../../../utils/validators/is-boolean" export const AddressPayload = z .object({ @@ -24,3 +25,8 @@ export const BigNumberInput = z.union([ precision: z.number(), }), ]) + +export const OptionalBooleanValidator = z.preprocess( + (val: any) => optionalBooleanMapper.get(val?.toLowerCase()), + z.boolean().optional() +) diff --git a/packages/medusa/src/api-v2/utils/common-validators/index.ts b/packages/medusa/src/api-v2/utils/common-validators/index.ts new file mode 100644 index 0000000000..b8bf5d953e --- /dev/null +++ b/packages/medusa/src/api-v2/utils/common-validators/index.ts @@ -0,0 +1,2 @@ +export * from "./common" +export * from "./products" diff --git a/packages/medusa/src/api-v2/utils/common-validators/products/index.ts b/packages/medusa/src/api-v2/utils/common-validators/products/index.ts new file mode 100644 index 0000000000..240b0c36f1 --- /dev/null +++ b/packages/medusa/src/api-v2/utils/common-validators/products/index.ts @@ -0,0 +1,22 @@ +import { ProductStatus } from "@medusajs/utils" +import { z } from "zod" +import { createOperatorMap } from "../../validators" +import { OptionalBooleanValidator } from "../common" + +export const ProductStatusEnum = z.nativeEnum(ProductStatus) + +export const GetProductsParams = z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + title: z.string().optional(), + handle: z.string().optional(), + is_giftcard: OptionalBooleanValidator, + category_id: z.string().array().optional(), + sales_channel_id: z.string().array().optional(), + collection_id: z.string().array().optional(), + tags: z.string().array().optional(), + type_id: z.string().array().optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), +}) diff --git a/packages/medusa/src/api-v2/utils/middlewares/common/apply-default-filters.ts b/packages/medusa/src/api-v2/utils/middlewares/common/apply-default-filters.ts new file mode 100644 index 0000000000..efc3b68258 --- /dev/null +++ b/packages/medusa/src/api-v2/utils/middlewares/common/apply-default-filters.ts @@ -0,0 +1,27 @@ +import { isObject } from "@medusajs/utils" +import { NextFunction } from "express" +import { MedusaRequest } from "../../../../types/routing" + +export function applyDefaultFilters(filters: TFilter) { + return async (req: MedusaRequest, _, next: NextFunction) => { + const filterableFields = req.filterableFields || {} + + for (const [filter, filterValue] of Object.entries(filters)) { + let existingFilter = filterableFields[filter] + + if (existingFilter && isObject(existingFilter)) { + // If an existing filter is found, append to it + filterableFields[filter] = { + ...existingFilter, + [filter]: filterValue, + } + } else { + filterableFields[filter] = filterValue + } + } + + req.filterableFields = filterableFields + + return next() + } +} diff --git a/packages/medusa/src/api-v2/utils/middlewares/common/index.ts b/packages/medusa/src/api-v2/utils/middlewares/common/index.ts new file mode 100644 index 0000000000..a0bd86ad38 --- /dev/null +++ b/packages/medusa/src/api-v2/utils/middlewares/common/index.ts @@ -0,0 +1 @@ +export * from "./apply-default-filters" diff --git a/packages/medusa/src/api-v2/utils/middlewares/index.ts b/packages/medusa/src/api-v2/utils/middlewares/index.ts new file mode 100644 index 0000000000..b8bf5d953e --- /dev/null +++ b/packages/medusa/src/api-v2/utils/middlewares/index.ts @@ -0,0 +1,2 @@ +export * from "./common" +export * from "./products" diff --git a/packages/medusa/src/api-v2/utils/middlewares/products/filter-by-valid-sales-channels.ts b/packages/medusa/src/api-v2/utils/middlewares/products/filter-by-valid-sales-channels.ts new file mode 100644 index 0000000000..b4faa4b92c --- /dev/null +++ b/packages/medusa/src/api-v2/utils/middlewares/products/filter-by-valid-sales-channels.ts @@ -0,0 +1,79 @@ +import { isPresent, MedusaError } from "@medusajs/utils" +import { NextFunction } from "express" +import { AuthenticatedMedusaRequest } from "../../../../types/routing" +import { refetchEntity } from "../../refetch-entity" + +export function filterByValidSalesChannels() { + return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => { + const publishableApiKey = req.get("x-publishable-api-key") + const salesChannelIds = req.filterableFields.sales_channel_id as + | string[] + | undefined + + const store = await refetchEntity("stores", {}, req.scope, [ + "default_sales_channel_id", + ]) + + if (!store) { + try { + throw new MedusaError(MedusaError.Types.INVALID_DATA, `Store not found`) + } catch (e) { + return next(e) + } + } + // Always set sales channels in the following priority + // - any existing sales chennel ids passed through query params + // - if none, we set the default sales channel + req.filterableFields.sales_channel_id = salesChannelIds ?? [ + store.default_sales_channel_id, + ] + + // Return early if no publishable keys are found + if (!isPresent(publishableApiKey)) { + return next() + } + + // When publishable keys are present, we fetch for all sales chennels attached + // to the publishable key and validate the sales channel filter against it + const apiKey = await refetchEntity( + "api_key", + { token: publishableApiKey }, + req.scope, + ["id", "sales_channels_link.sales_channel_id"] + ) + + if (!apiKey) { + try { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Publishable API key not found` + ) + } catch (e) { + return next(e) + } + } + + const validSalesChannelIds = apiKey.sales_channels_link.map( + (link) => link.sales_channel_id + ) + + const invalidSalesChannelIds = (salesChannelIds || []).filter( + (id) => !validSalesChannelIds.includes(id) + ) + + if (invalidSalesChannelIds.length) { + try { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Invalid sales channel filters provided - ${invalidSalesChannelIds.join( + ", " + )}` + ) + } catch (e) { + return next(e) + } + } + + return next() + } +} diff --git a/packages/medusa/src/api-v2/utils/middlewares/products/index.ts b/packages/medusa/src/api-v2/utils/middlewares/products/index.ts new file mode 100644 index 0000000000..a2b3d9daf0 --- /dev/null +++ b/packages/medusa/src/api-v2/utils/middlewares/products/index.ts @@ -0,0 +1,2 @@ +export * from "./filter-by-valid-sales-channels" +export * from "./set-pricing-context" diff --git a/packages/medusa/src/api-v2/utils/middlewares/products/set-pricing-context.ts b/packages/medusa/src/api-v2/utils/middlewares/products/set-pricing-context.ts new file mode 100644 index 0000000000..e2107de2aa --- /dev/null +++ b/packages/medusa/src/api-v2/utils/middlewares/products/set-pricing-context.ts @@ -0,0 +1,89 @@ +import { MedusaPricingContext } from "@medusajs/types" +import { isPresent, MedusaError } from "@medusajs/utils" +import { NextFunction } from "express" +import { AuthenticatedMedusaRequest } from "../../../../types/routing" +import { refetchEntities, refetchEntity } from "../../refetch-entity" + +export function setPricingContext() { + return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => { + // If the endpoint doesn't request prices, we can exit early + if ( + !req.remoteQueryConfig.fields.some((field) => + field.startsWith("variants.calculated_price") + ) + ) { + delete req.filterableFields.region_id + delete req.filterableFields.currency_code + + return next() + } + + const query = req.filterableFields || {} + const pricingContext: MedusaPricingContext = {} + const customerId = req.user?.customer_id + + if (query.region_id) { + const region = await refetchEntity("region", query.region_id, req.scope, [ + "id", + "currency_code", + ]) + + if (region) { + pricingContext.region_id = region.id + } + + if (region?.currency_code) { + pricingContext.currency_code = region.currency_code + } + + delete req.filterableFields.region_id + } + + // If a currency code is explicitly passed, we should be using that instead of the + // regions currency code + if (query.currency_code) { + const currency = await refetchEntity( + "currency", + { code: query.currency_code }, + req.scope, + ["code"] + ) + + if (currency) { + pricingContext.currency_code = currency.code + } + + delete req.filterableFields.currency_code + } + + // Find all the customer groups the customer is a part of and set + if (customerId) { + const customerGroups = await refetchEntities( + "customer_group", + { customer_id: customerId }, + req.scope, + ["id"] + ) + + pricingContext.customer_group_id = customerGroups.map((cg) => cg.id) + + delete req.filterableFields.customer_id + } + + // If a region or currency_code is not present in the context, we will not be able to calculate prices + if (!isPresent(pricingContext)) { + try { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Pricing parameters (currency_code or region_id) are required to calculate prices` + ) + } catch (e) { + return next(e) + } + } + + req.pricingContext = pricingContext + + return next() + } +} diff --git a/packages/medusa/src/api-v2/utils/refetch-entity.ts b/packages/medusa/src/api-v2/utils/refetch-entity.ts new file mode 100644 index 0000000000..b49406d1d2 --- /dev/null +++ b/packages/medusa/src/api-v2/utils/refetch-entity.ts @@ -0,0 +1,46 @@ +import { MedusaContainer } from "@medusajs/types" +import { + ContainerRegistrationKeys, + isString, + remoteQueryObjectFromString, +} from "@medusajs/utils" + +export const refetchEntities = async ( + entryPoint: string, + idOrFilter: string | object, + scope: MedusaContainer, + fields: string[] +) => { + const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + const filters = isString(idOrFilter) ? { id: idOrFilter } : idOrFilter + let context: object = {} + + if ("context" in filters) { + if (filters.context) { + context = filters.context! + } + + delete filters.context + } + + let variables = { filters, ...context } + + const queryObject = remoteQueryObjectFromString({ + entryPoint, + variables, + fields, + }) + + return await remoteQuery(queryObject) +} + +export const refetchEntity = async ( + entryPoint: string, + idOrFilter: string | object, + scope: MedusaContainer, + fields: string[] +) => { + const [entity] = await refetchEntities(entryPoint, idOrFilter, scope, fields) + + return entity +} diff --git a/packages/medusa/src/api-v2/utils/validate-query.ts b/packages/medusa/src/api-v2/utils/validate-query.ts index 0933a59dc3..30c5e6d43a 100644 --- a/packages/medusa/src/api-v2/utils/validate-query.ts +++ b/packages/medusa/src/api-v2/utils/validate-query.ts @@ -44,7 +44,6 @@ export function validateAndTransformQuery( return async (req: MedusaRequest, _: MedusaResponse, next: NextFunction) => { try { const query = normalizeQuery(req) - const validated = await zodValidator(zodSchema, query) const cnf = queryConfig.isList ? prepareListQuery(validated, queryConfig) diff --git a/packages/medusa/src/types/routing.ts b/packages/medusa/src/types/routing.ts index 6c49652d0a..8aedf22874 100644 --- a/packages/medusa/src/types/routing.ts +++ b/packages/medusa/src/types/routing.ts @@ -1,9 +1,13 @@ import type { NextFunction, Request, Response } from "express" import type { Customer, User } from "../models" -import { MedusaContainer, RequestQueryFields } from "@medusajs/types" -import { FindConfig } from "./common" +import { + MedusaContainer, + MedusaPricingContext, + RequestQueryFields, +} from "@medusajs/types" import * as core from "express-serve-static-core" +import { FindConfig } from "./common" export interface MedusaRequest extends Request { @@ -50,6 +54,10 @@ export interface MedusaRequest session?: any rawBody?: any requestId?: string + /** + * An object that carries the context that is used to calculate prices for variants + */ + pricingContext?: MedusaPricingContext } export interface AuthenticatedMedusaRequest diff --git a/packages/types/src/pricing/common/index.ts b/packages/types/src/pricing/common/index.ts index 7f58189cbb..771a2bc34d 100644 --- a/packages/types/src/pricing/common/index.ts +++ b/packages/types/src/pricing/common/index.ts @@ -4,4 +4,5 @@ export * from "./price-list" export * from "./price-rule" export * from "./price-set" export * from "./price-set-rule-type" +export * from "./pricing-context" export * from "./rule-type" diff --git a/packages/types/src/pricing/common/pricing-context.ts b/packages/types/src/pricing/common/pricing-context.ts new file mode 100644 index 0000000000..13afe18ef4 --- /dev/null +++ b/packages/types/src/pricing/common/pricing-context.ts @@ -0,0 +1,6 @@ +export type MedusaPricingContext = { + region_id?: string + currency_code?: string + customer_id?: string + customer_group_id?: string[] +}