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
This commit is contained in:
Frane Polić
2025-01-26 14:16:49 +01:00
committed by GitHub
parent 1d7eeb53ae
commit d4cbc6218c
22 changed files with 507 additions and 3 deletions

View File

@@ -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" })])
)
})
})
},
})

View File

@@ -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" })])
)
})
})
},
})

View File

@@ -4,6 +4,9 @@ import {
} from "../common"
export interface StoreProductCategoryListParams
extends Omit<BaseProductCategoryListParams, "is_internal" | "is_active" | "deleted_at"> {}
extends Omit<
BaseProductCategoryListParams,
"is_internal" | "is_active" | "deleted_at"
> {}
export interface StoreProductCategoryParams extends BaseProductCategoryParams {}

View File

@@ -1,2 +1,3 @@
export * from "./entities"
export * from "./queries"
export * from "./responses"

View File

@@ -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[]
}> {}

View File

@@ -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<string, unknown> | 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<string>
/**
* Apply filters on the update date.
*/
updated_at?: OperatorMap<string>
}

View File

@@ -1 +1,3 @@
export * from "./entities"
export * from "./queries"
export * from "./responses"

View File

@@ -0,0 +1,9 @@
import { BaseFilterable } from "../../../dal"
import { SelectParams } from "../../common"
import { BaseProductTypeListParams } from "../common"
export interface StoreProductTypeListParams
extends BaseProductTypeListParams,
BaseFilterable<StoreProductTypeListParams> {}
export interface StoreProductTypeParams extends SelectParams {}

View File

@@ -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[]
}> {}

View File

@@ -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,

View File

@@ -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<StoreProductTagParamsType>,
res: MedusaResponse<StoreProductTagResponse>
) => {
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] })
}

View File

@@ -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
),
],
},
]

View File

@@ -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,
}

View File

@@ -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<StoreProductTagsParamsType>,
res: MedusaResponse<StoreProductTagListResponse>
) => {
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,
})
}

View File

@@ -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<typeof StoreProductTagParams>
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<typeof StoreProductTagsParams>
export const StoreProductTagsParams = createFindParams({
offset: 0,
limit: 50,
})
.merge(StoreProductTagsParamsFields)
.merge(applyAndAndOrOperators(StoreProductTagsParamsFields))

View File

@@ -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<StoreProductTypeParamsType>,
res: MedusaResponse<StoreProductTypeResponse>
) => {
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] })
}

View File

@@ -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
),
],
},
]

View File

@@ -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,
}

View File

@@ -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<StoreProductTypesParamsType>,
res: MedusaResponse<StoreProductTypeListResponse>
) => {
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,
})
}

View File

@@ -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<typeof StoreProductTypeParams>
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))

View File

@@ -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",
}),
})

View File

@@ -36,7 +36,7 @@ const Product = model
}),
type: model
.belongsTo(() => ProductType, {
mappedBy: "product",
mappedBy: "products",
})
.nullable(),
tags: model.manyToMany(() => ProductTag, {