From d4cbc6218ceb502f0b391c61648a778631bd707d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Sun, 26 Jan 2025 14:16:49 +0100 Subject: [PATCH] feat(medusa,product,types): product type & tag store missing endpoints (#11057) * wip: tag endpoints * feat: types, product types * feat: tests * fix: update product type schema --- .../product-tag/store/product-tag.spec.ts | 75 +++++++++++++++++++ .../product-type/store/product-type.spec.ts | 75 +++++++++++++++++++ .../http/product-category/store/queries.ts | 5 +- .../types/src/http/product-tag/store/index.ts | 1 + .../src/http/product-tag/store/responses.ts | 17 +++++ .../types/src/http/product-type/common.ts | 26 +++++++ .../src/http/product-type/store/index.ts | 2 + .../src/http/product-type/store/queries.ts | 9 +++ .../src/http/product-type/store/responses.ts | 17 +++++ packages/medusa/src/api/middlewares.ts | 4 + .../src/api/store/product-tags/[id]/route.ts | 34 +++++++++ .../src/api/store/product-tags/middlewares.ts | 27 +++++++ .../api/store/product-tags/query-config.ts | 19 +++++ .../src/api/store/product-tags/route.ts | 28 +++++++ .../src/api/store/product-tags/validators.ts | 28 +++++++ .../src/api/store/product-types/[id]/route.ts | 34 +++++++++ .../api/store/product-types/middlewares.ts | 28 +++++++ .../api/store/product-types/query-config.ts | 19 +++++ .../src/api/store/product-types/route.ts | 28 +++++++ .../src/api/store/product-types/validators.ts | 30 ++++++++ .../product/src/models/product-type.ts | 2 +- .../modules/product/src/models/product.ts | 2 +- 22 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 integration-tests/http/__tests__/product-tag/store/product-tag.spec.ts create mode 100644 integration-tests/http/__tests__/product-type/store/product-type.spec.ts create mode 100644 packages/core/types/src/http/product-tag/store/responses.ts create mode 100644 packages/core/types/src/http/product-type/store/queries.ts create mode 100644 packages/core/types/src/http/product-type/store/responses.ts create mode 100644 packages/medusa/src/api/store/product-tags/[id]/route.ts create mode 100644 packages/medusa/src/api/store/product-tags/middlewares.ts create mode 100644 packages/medusa/src/api/store/product-tags/query-config.ts create mode 100644 packages/medusa/src/api/store/product-tags/route.ts create mode 100644 packages/medusa/src/api/store/product-tags/validators.ts create mode 100644 packages/medusa/src/api/store/product-types/[id]/route.ts create mode 100644 packages/medusa/src/api/store/product-types/middlewares.ts create mode 100644 packages/medusa/src/api/store/product-types/query-config.ts create mode 100644 packages/medusa/src/api/store/product-types/route.ts create mode 100644 packages/medusa/src/api/store/product-types/validators.ts diff --git a/integration-tests/http/__tests__/product-tag/store/product-tag.spec.ts b/integration-tests/http/__tests__/product-tag/store/product-tag.spec.ts new file mode 100644 index 0000000000..51f4f54a47 --- /dev/null +++ b/integration-tests/http/__tests__/product-tag/store/product-tag.spec.ts @@ -0,0 +1,75 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { + createAdminUser, + adminHeaders, + generatePublishableKey, + generateStoreHeaders, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + env: {}, + testSuite: ({ dbConnection, getContainer, api }) => { + let tag1 + let tag2 + let publishableKey + let storeHeaders + + beforeEach(async () => { + const container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + + publishableKey = await generatePublishableKey(container) + storeHeaders = generateStoreHeaders({ publishableKey }) + + tag1 = ( + await api.post( + "/admin/product-types", + { + value: "test1", + }, + adminHeaders + ) + ).data.product_type + + tag2 = ( + await api.post( + "/admin/product-types", + { + value: "test2", + }, + adminHeaders + ) + ).data.product_type + }) + + describe("GET /store/product-types", () => { + it("returns a list of product types", async () => { + const res = await api.get("/store/product-types", storeHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_types).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: "test1", + }), + expect.objectContaining({ + value: "test2", + }), + ]) + ) + }) + + it("returns a list of product types matching free text search param", async () => { + const res = await api.get("/store/product-types?q=1", storeHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_types.length).toEqual(1) + expect(res.data.product_types).toEqual( + expect.arrayContaining([expect.objectContaining({ value: "test1" })]) + ) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/product-type/store/product-type.spec.ts b/integration-tests/http/__tests__/product-type/store/product-type.spec.ts new file mode 100644 index 0000000000..f2a9ab653a --- /dev/null +++ b/integration-tests/http/__tests__/product-type/store/product-type.spec.ts @@ -0,0 +1,75 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { + createAdminUser, + adminHeaders, + generatePublishableKey, + generateStoreHeaders, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + env: {}, + testSuite: ({ dbConnection, getContainer, api }) => { + let tag1 + let tag2 + let publishableKey + let storeHeaders + + beforeEach(async () => { + const container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + + publishableKey = await generatePublishableKey(container) + storeHeaders = generateStoreHeaders({ publishableKey }) + + tag1 = ( + await api.post( + "/admin/product-tags", + { + value: "test1", + }, + adminHeaders + ) + ).data.product_tag + + tag2 = ( + await api.post( + "/admin/product-tags", + { + value: "test2", + }, + adminHeaders + ) + ).data.product_tag + }) + + describe("GET /store/product-tags", () => { + it("returns a list of product tags", async () => { + const res = await api.get("/store/product-tags", storeHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: "test1", + }), + expect.objectContaining({ + value: "test2", + }), + ]) + ) + }) + + it("returns a list of product tags matching free text search param", async () => { + const res = await api.get("/admin/product-tags?q=1", adminHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_tags.length).toEqual(1) + expect(res.data.product_tags).toEqual( + expect.arrayContaining([expect.objectContaining({ value: "test1" })]) + ) + }) + }) + }, +}) diff --git a/packages/core/types/src/http/product-category/store/queries.ts b/packages/core/types/src/http/product-category/store/queries.ts index 5539396adc..1996913d31 100644 --- a/packages/core/types/src/http/product-category/store/queries.ts +++ b/packages/core/types/src/http/product-category/store/queries.ts @@ -4,6 +4,9 @@ import { } from "../common" export interface StoreProductCategoryListParams - extends Omit {} + extends Omit< + BaseProductCategoryListParams, + "is_internal" | "is_active" | "deleted_at" + > {} export interface StoreProductCategoryParams extends BaseProductCategoryParams {} diff --git a/packages/core/types/src/http/product-tag/store/index.ts b/packages/core/types/src/http/product-tag/store/index.ts index 29057d02ce..020c34f02c 100644 --- a/packages/core/types/src/http/product-tag/store/index.ts +++ b/packages/core/types/src/http/product-tag/store/index.ts @@ -1,2 +1,3 @@ export * from "./entities" export * from "./queries" +export * from "./responses" diff --git a/packages/core/types/src/http/product-tag/store/responses.ts b/packages/core/types/src/http/product-tag/store/responses.ts new file mode 100644 index 0000000000..df0a201b82 --- /dev/null +++ b/packages/core/types/src/http/product-tag/store/responses.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse } from "../../common" +import { StoreProductTag } from "./entities" + +export interface StoreProductTagResponse { + /** + * The tag's details. + */ + product_tag: StoreProductTag +} + +export interface StoreProductTagListResponse + extends PaginatedResponse<{ + /** + * The paginated list of tags. + */ + product_tags: StoreProductTag[] + }> {} diff --git a/packages/core/types/src/http/product-type/common.ts b/packages/core/types/src/http/product-type/common.ts index 43a3bd2ed4..a9ad1e5adb 100644 --- a/packages/core/types/src/http/product-type/common.ts +++ b/packages/core/types/src/http/product-type/common.ts @@ -1,3 +1,6 @@ +import { OperatorMap } from "../../dal" +import { FindParams } from "../common" + export interface BaseProductType { /** * The product type's ID. @@ -24,3 +27,26 @@ export interface BaseProductType { */ metadata?: Record | null } + +export interface BaseProductTypeListParams extends FindParams { + /** + * Query or keyword to apply on the type's searchable fields. + */ + q?: string + /** + * Filter by type ID(s). + */ + id?: string | string[] + /** + * Filter by value(s). + */ + value?: string | string[] + /** + * Apply filters on the creation date. + */ + created_at?: OperatorMap + /** + * Apply filters on the update date. + */ + updated_at?: OperatorMap +} diff --git a/packages/core/types/src/http/product-type/store/index.ts b/packages/core/types/src/http/product-type/store/index.ts index 8270e0b265..020c34f02c 100644 --- a/packages/core/types/src/http/product-type/store/index.ts +++ b/packages/core/types/src/http/product-type/store/index.ts @@ -1 +1,3 @@ export * from "./entities" +export * from "./queries" +export * from "./responses" diff --git a/packages/core/types/src/http/product-type/store/queries.ts b/packages/core/types/src/http/product-type/store/queries.ts new file mode 100644 index 0000000000..3b7f3bc205 --- /dev/null +++ b/packages/core/types/src/http/product-type/store/queries.ts @@ -0,0 +1,9 @@ +import { BaseFilterable } from "../../../dal" +import { SelectParams } from "../../common" +import { BaseProductTypeListParams } from "../common" + +export interface StoreProductTypeListParams + extends BaseProductTypeListParams, + BaseFilterable {} + +export interface StoreProductTypeParams extends SelectParams {} diff --git a/packages/core/types/src/http/product-type/store/responses.ts b/packages/core/types/src/http/product-type/store/responses.ts new file mode 100644 index 0000000000..05880de9da --- /dev/null +++ b/packages/core/types/src/http/product-type/store/responses.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse } from "../../common" +import { StoreProductType } from "./entities" + +export interface StoreProductTypeResponse { + /** + * The type's details. + */ + product_type: StoreProductType +} + +export interface StoreProductTypeListResponse + extends PaginatedResponse<{ + /** + * The paginated list of types. + */ + product_types: StoreProductType[] + }> {} diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index d61f396d9e..3fe4735c2a 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -54,6 +54,8 @@ import { storePaymentCollectionsMiddlewares } from "./store/payment-collections/ import { storePaymentProvidersMiddlewares } from "./store/payment-providers/middlewares" import { storeProductCategoryRoutesMiddlewares } from "./store/product-categories/middlewares" import { storeProductRoutesMiddlewares } from "./store/products/middlewares" +import { storeProductTagRoutesMiddlewares } from "./store/product-tags/middlewares" +import { storeProductTypeRoutesMiddlewares } from "./store/product-types/middlewares" import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares" import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middlewares" import { storeShippingOptionRoutesMiddlewares } from "./store/shipping-options/middlewares" @@ -69,6 +71,8 @@ export default defineMiddlewares([ ...storeCartRoutesMiddlewares, ...storeCollectionRoutesMiddlewares, ...storeProductCategoryRoutesMiddlewares, + ...storeProductTagRoutesMiddlewares, + ...storeProductTypeRoutesMiddlewares, ...storePaymentProvidersMiddlewares, ...storeShippingOptionRoutesMiddlewares, ...storePaymentCollectionsMiddlewares, diff --git a/packages/medusa/src/api/store/product-tags/[id]/route.ts b/packages/medusa/src/api/store/product-tags/[id]/route.ts new file mode 100644 index 0000000000..109cafe43e --- /dev/null +++ b/packages/medusa/src/api/store/product-tags/[id]/route.ts @@ -0,0 +1,34 @@ +import { StoreProductTagResponse } from "@medusajs/framework/types" +import { + ContainerRegistrationKeys, + MedusaError, +} from "@medusajs/framework/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" + +import { StoreProductTagParamsType } from "../validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data } = await query.graph({ + entity: "product_tag", + filters: { + id: req.params.id, + }, + fields: req.queryConfig.fields, + }) + + if (!data.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product tag with id: ${req.params.id} was not found` + ) + } + res.json({ product_tag: data[0] }) +} diff --git a/packages/medusa/src/api/store/product-tags/middlewares.ts b/packages/medusa/src/api/store/product-tags/middlewares.ts new file mode 100644 index 0000000000..a425e04319 --- /dev/null +++ b/packages/medusa/src/api/store/product-tags/middlewares.ts @@ -0,0 +1,27 @@ +import { MiddlewareRoute } from "@medusajs/framework/http" +import { validateAndTransformQuery } from "@medusajs/framework" +import * as QueryConfig from "./query-config" +import { StoreProductTagsParams, StoreProductTagParams } from "./validators" + +export const storeProductTagRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/store/product-tags", + middlewares: [ + validateAndTransformQuery( + StoreProductTagsParams, + QueryConfig.listProductTagConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/store/product-tags/:id", + middlewares: [ + validateAndTransformQuery( + StoreProductTagParams, + QueryConfig.retrieveProductTagConfig + ), + ], + }, +] diff --git a/packages/medusa/src/api/store/product-tags/query-config.ts b/packages/medusa/src/api/store/product-tags/query-config.ts new file mode 100644 index 0000000000..2b10cb8289 --- /dev/null +++ b/packages/medusa/src/api/store/product-tags/query-config.ts @@ -0,0 +1,19 @@ +export const defaults = [ + "id", + "value", + "created_at", + "updated_at", + "metadata", + "*products", +] + +export const retrieveProductTagConfig = { + defaults, + isList: false, +} + +export const listProductTagConfig = { + defaults, + defaultLimit: 50, + isList: true, +} diff --git a/packages/medusa/src/api/store/product-tags/route.ts b/packages/medusa/src/api/store/product-tags/route.ts new file mode 100644 index 0000000000..136080d350 --- /dev/null +++ b/packages/medusa/src/api/store/product-tags/route.ts @@ -0,0 +1,28 @@ +import { StoreProductTagListResponse } from "@medusajs/framework/types" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { StoreProductTagsParamsType } from "./validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: product_tags, metadata } = await query.graph({ + entity: "product_tag", + filters: req.filterableFields, + pagination: req.queryConfig.pagination, + fields: req.queryConfig.fields, + }) + + res.json({ + product_tags, + count: metadata?.count ?? 0, + offset: metadata?.skip ?? 0, + limit: metadata?.take ?? 0, + }) +} diff --git a/packages/medusa/src/api/store/product-tags/validators.ts b/packages/medusa/src/api/store/product-tags/validators.ts new file mode 100644 index 0000000000..1bf00e8a2b --- /dev/null +++ b/packages/medusa/src/api/store/product-tags/validators.ts @@ -0,0 +1,28 @@ +import { z } from "zod" +import { applyAndAndOrOperators } from "../../utils/common-validators" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../utils/validators" + +export type StoreProductTagParamsType = z.infer + +export const StoreProductTagParams = createSelectParams().merge(z.object({})) + +export const StoreProductTagsParamsFields = z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + value: z.union([z.string(), z.array(z.string())]).optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), +}) + +export type StoreProductTagsParamsType = z.infer +export const StoreProductTagsParams = createFindParams({ + offset: 0, + limit: 50, +}) + .merge(StoreProductTagsParamsFields) + .merge(applyAndAndOrOperators(StoreProductTagsParamsFields)) diff --git a/packages/medusa/src/api/store/product-types/[id]/route.ts b/packages/medusa/src/api/store/product-types/[id]/route.ts new file mode 100644 index 0000000000..713811e9d6 --- /dev/null +++ b/packages/medusa/src/api/store/product-types/[id]/route.ts @@ -0,0 +1,34 @@ +import { StoreProductTypeResponse } from "@medusajs/framework/types" +import { + ContainerRegistrationKeys, + MedusaError, +} from "@medusajs/framework/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" + +import { StoreProductTypeParamsType } from "../validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data } = await query.graph({ + entity: "product_type", + filters: { + id: req.params.id, + }, + fields: req.queryConfig.fields, + }) + + if (!data.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product type with id: ${req.params.id} was not found` + ) + } + res.json({ product_type: data[0] }) +} diff --git a/packages/medusa/src/api/store/product-types/middlewares.ts b/packages/medusa/src/api/store/product-types/middlewares.ts new file mode 100644 index 0000000000..299f87ccd3 --- /dev/null +++ b/packages/medusa/src/api/store/product-types/middlewares.ts @@ -0,0 +1,28 @@ +import { MiddlewareRoute } from "@medusajs/framework/http" +import { validateAndTransformQuery } from "@medusajs/framework" +import * as QueryConfig from "./query-config" + +import { StoreProductTypesParams } from "./validators" + +export const storeProductTypeRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/store/product-types", + middlewares: [ + validateAndTransformQuery( + StoreProductTypesParams, + QueryConfig.listProductTypeConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/store/product-types/:id", + middlewares: [ + validateAndTransformQuery( + StoreProductTypesParams, + QueryConfig.retrieveProductTypeConfig + ), + ], + }, +] diff --git a/packages/medusa/src/api/store/product-types/query-config.ts b/packages/medusa/src/api/store/product-types/query-config.ts new file mode 100644 index 0000000000..00d1308a32 --- /dev/null +++ b/packages/medusa/src/api/store/product-types/query-config.ts @@ -0,0 +1,19 @@ +export const defaults = [ + "id", + "value", + "created_at", + "updated_at", + "metadata", + "*products", +] + +export const retrieveProductTypeConfig = { + defaults, + isList: false, +} + +export const listProductTypeConfig = { + defaults, + defaultLimit: 50, + isList: true, +} diff --git a/packages/medusa/src/api/store/product-types/route.ts b/packages/medusa/src/api/store/product-types/route.ts new file mode 100644 index 0000000000..71d73ef043 --- /dev/null +++ b/packages/medusa/src/api/store/product-types/route.ts @@ -0,0 +1,28 @@ +import { StoreProductTypeListResponse } from "@medusajs/framework/types" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { StoreProductTypesParamsType } from "./validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: product_types, metadata } = await query.graph({ + entity: "product_type", + filters: req.filterableFields, + pagination: req.queryConfig.pagination, + fields: req.queryConfig.fields, + }) + + res.json({ + product_types, + count: metadata?.count ?? 0, + offset: metadata?.skip ?? 0, + limit: metadata?.take ?? 0, + }) +} diff --git a/packages/medusa/src/api/store/product-types/validators.ts b/packages/medusa/src/api/store/product-types/validators.ts new file mode 100644 index 0000000000..f85c9f3973 --- /dev/null +++ b/packages/medusa/src/api/store/product-types/validators.ts @@ -0,0 +1,30 @@ +import { z } from "zod" +import { applyAndAndOrOperators } from "../../utils/common-validators" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../utils/validators" + +export type StoreProductTypeParamsType = z.infer + +export const StoreProductTypeParams = createSelectParams().merge(z.object({})) + +export const StoreProductTypesParamsFields = z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + value: z.union([z.string(), z.array(z.string())]).optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), +}) + +export type StoreProductTypesParamsType = z.infer< + typeof StoreProductTypesParams +> +export const StoreProductTypesParams = createFindParams({ + offset: 0, + limit: 50, +}) + .merge(StoreProductTypesParamsFields) + .merge(applyAndAndOrOperators(StoreProductTypesParamsFields)) diff --git a/packages/modules/product/src/models/product-type.ts b/packages/modules/product/src/models/product-type.ts index 904c9ee6c0..789b62091e 100644 --- a/packages/modules/product/src/models/product-type.ts +++ b/packages/modules/product/src/models/product-type.ts @@ -6,7 +6,7 @@ const ProductType = model id: model.id({ prefix: "ptyp" }).primaryKey(), value: model.text().searchable(), metadata: model.json().nullable(), - product: model.hasMany(() => Product, { + products: model.hasMany(() => Product, { mappedBy: "type", }), }) diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index 3ad4129ed2..9123390f2a 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -36,7 +36,7 @@ const Product = model }), type: model .belongsTo(() => ProductType, { - mappedBy: "product", + mappedBy: "products", }) .nullable(), tags: model.manyToMany(() => ProductTag, {