diff --git a/.changeset/perfect-beers-notice.md b/.changeset/perfect-beers-notice.md new file mode 100644 index 0000000000..08b2b047a5 --- /dev/null +++ b/.changeset/perfect-beers-notice.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): Allow to query product tags by condition id diff --git a/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap index 50244b3fb8..f76cdb1d52 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap @@ -4,9 +4,9 @@ exports[`/admin/product-tags GET /admin/product-tags returns a list of product t Array [ Object { "created_at": Any, - "id": "tag1", + "id": "tag4", "updated_at": Any, - "value": "123", + "value": "1234", }, Object { "created_at": Any, @@ -16,9 +16,9 @@ Array [ }, Object { "created_at": Any, - "id": "tag4", + "id": "tag1", "updated_at": Any, - "value": "1234", + "value": "123", }, ] `; @@ -27,9 +27,9 @@ exports[`/admin/product-tags GET /admin/product-tags returns a list of product t Array [ Object { "created_at": Any, - "id": "tag1", + "id": "tag4", "updated_at": Any, - "value": "123", + "value": "1234", }, Object { "created_at": Any, @@ -39,9 +39,9 @@ Array [ }, Object { "created_at": Any, - "id": "tag4", + "id": "tag1", "updated_at": Any, - "value": "1234", + "value": "123", }, ] `; diff --git a/integration-tests/api/__tests__/admin/product-tag.js b/integration-tests/api/__tests__/admin/product-tag.js index d1a45f5115..af26d63c13 100644 --- a/integration-tests/api/__tests__/admin/product-tag.js +++ b/integration-tests/api/__tests__/admin/product-tag.js @@ -1,14 +1,28 @@ const path = require("path") +const { IdMap } = require("medusa-test-utils") + const setupServer = require("../../../helpers/setup-server") const { useApi } = require("../../../helpers/use-api") const { initDb, useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") const productSeeder = require("../../helpers/product-seeder") +const { + DiscountConditionType, + DiscountConditionOperator, +} = require("@medusajs/medusa") +const { simpleDiscountFactory } = require("../../factories") +const { DiscountRuleType, AllocationType } = require("@medusajs/medusa/dist") jest.setTimeout(50000) +const adminReqConfig = { + headers: { + Authorization: "Bearer test_token", + }, +} + describe("/admin/product-tags", () => { let medusaProcess let dbConnection @@ -28,8 +42,8 @@ describe("/admin/product-tags", () => { describe("GET /admin/product-tags", () => { beforeEach(async () => { - await productSeeder(dbConnection) await adminSeeder(dbConnection) + await productSeeder(dbConnection) }) afterEach(async () => { @@ -41,11 +55,7 @@ describe("/admin/product-tags", () => { const api = useApi() const res = await api - .get("/admin/product-tags", { - headers: { - Authorization: "Bearer test_token", - }, - }) + .get("/admin/product-tags", adminReqConfig) .catch((err) => { console.log(err) }) @@ -68,11 +78,7 @@ describe("/admin/product-tags", () => { const api = useApi() const res = await api - .get("/admin/product-tags?q=123", { - headers: { - Authorization: "Bearer test_token", - }, - }) + .get("/admin/product-tags?q=123", adminReqConfig) .catch((err) => { console.log(err) }) @@ -95,5 +101,78 @@ describe("/admin/product-tags", () => { tagMatch, ]) }) + + it("returns a list of product tags filtered by discount condition id", async () => { + const api = useApi() + + const resTags = await api.get("/admin/product-tags", adminReqConfig) + + const tag1 = resTags.data.product_tags[0] + const tag2 = resTags.data.product_tags[2] + + const buildDiscountData = (code, conditionId, tags) => { + return { + code, + rule: { + type: DiscountRuleType.PERCENTAGE, + value: 10, + allocation: AllocationType.TOTAL, + conditions: [ + { + id: conditionId, + type: DiscountConditionType.PRODUCT_TAGS, + operator: DiscountConditionOperator.IN, + product_tags: tags, + }, + ], + }, + } + } + + const discountConditionId = IdMap.getId("discount-condition-tag-1") + await simpleDiscountFactory( + dbConnection, + buildDiscountData("code-1", discountConditionId, [tag1.id]) + ) + + const discountConditionId2 = IdMap.getId("discount-condition-tag-2") + await simpleDiscountFactory( + dbConnection, + buildDiscountData("code-2", discountConditionId2, [tag2.id]) + ) + + let res = await api.get( + `/admin/product-tags?discount_condition_id=${discountConditionId}`, + adminReqConfig + ) + + expect(res.status).toEqual(200) + expect(res.data.product_tags).toHaveLength(1) + expect(res.data.product_tags).toEqual( + expect.arrayContaining([expect.objectContaining({ id: tag1.id })]) + ) + + res = await api.get( + `/admin/product-tags?discount_condition_id=${discountConditionId2}`, + adminReqConfig + ) + + expect(res.status).toEqual(200) + expect(res.data.product_tags).toHaveLength(1) + expect(res.data.product_tags).toEqual( + expect.arrayContaining([expect.objectContaining({ id: tag2.id })]) + ) + + res = await api.get(`/admin/product-tags`, adminReqConfig) + + expect(res.status).toEqual(200) + expect(res.data.product_tags).toHaveLength(3) + expect(res.data.product_tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: tag1.id }), + expect.objectContaining({ id: tag2.id }), + ]) + ) + }) }) }) diff --git a/packages/medusa/src/api/routes/admin/orders/index.ts b/packages/medusa/src/api/routes/admin/orders/index.ts index 64a6232d18..7805e0fbd5 100644 --- a/packages/medusa/src/api/routes/admin/orders/index.ts +++ b/packages/medusa/src/api/routes/admin/orders/index.ts @@ -1,15 +1,15 @@ import { Router } from "express" import "reflect-metadata" import { Order } from "../../../.." -import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import { DeleteResponse, FindParams, PaginatedResponse, } from "../../../../types/common" -import { FlagRouter } from "../../../../utils/flag-router" import middlewares, { transformQuery } from "../../../middlewares" import { AdminGetOrdersParams } from "./list-orders" +import { FlagRouter } from "../../../../utils/flag-router" +import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" const route = Router() diff --git a/packages/medusa/src/api/routes/admin/product-tags/index.ts b/packages/medusa/src/api/routes/admin/product-tags/index.ts index f7becf15ae..62f80c0165 100644 --- a/packages/medusa/src/api/routes/admin/product-tags/index.ts +++ b/packages/medusa/src/api/routes/admin/product-tags/index.ts @@ -1,15 +1,25 @@ import { Router } from "express" import { ProductTag } from "../../../.." import { PaginatedResponse } from "../../../../types/common" -import middlewares from "../../../middlewares" +import middlewares, { transformQuery } from "../../../middlewares" import "reflect-metadata" +import { AdminGetProductTagsParams } from "./list-product-tags" const route = Router() export default (app) => { app.use("/product-tags", route) - route.get("/", middlewares.wrap(require("./list-product-tags").default)) + route.get( + "/", + transformQuery(AdminGetProductTagsParams, { + defaultFields: defaultAdminProductTagsFields, + defaultRelations: defaultAdminProductTagsRelations, + allowedFields: allowedAdminProductTagsFields, + isList: true, + }), + middlewares.wrap(require("./list-product-tags").default) + ) return app } diff --git a/packages/medusa/src/api/routes/admin/product-tags/list-product-tags.ts b/packages/medusa/src/api/routes/admin/product-tags/list-product-tags.ts index fe552a0a91..b8a646ecc6 100644 --- a/packages/medusa/src/api/routes/admin/product-tags/list-product-tags.ts +++ b/packages/medusa/src/api/routes/admin/product-tags/list-product-tags.ts @@ -1,23 +1,13 @@ import { DateComparisonOperator, - FindConfig, StringComparisonOperator, } from "../../../../types/common" import { IsNumber, IsOptional, IsString } from "class-validator" -import { - allowedAdminProductTagsFields, - defaultAdminProductTagsFields, - defaultAdminProductTagsRelations, -} from "." -import { identity, omit, pickBy } from "lodash" import { IsType } from "../../../../utils/validators/is-type" -import { MedusaError } from "medusa-core-utils" -import { ProductTag } from "../../../../models/product-tag" import ProductTagService from "../../../../services/product-tag" import { Type } from "class-transformer" -import { validator } from "../../../../utils/validator" -import { isDefined } from "../../../../utils" +import { Request, Response } from "express" /** * @oas [get] /product-tags @@ -29,6 +19,7 @@ import { isDefined } from "../../../../utils" * - (query) limit=10 {integer} The number of tags to return. * - (query) offset=0 {integer} The number of items to skip before the results. * - (query) order {string} The field to sort items by. + * - (query) discount_condition_id {string} The discount condition id on which to filter the tags. * - in: query * name: value * style: form @@ -144,48 +135,21 @@ import { isDefined } from "../../../../utils" * "500": * $ref: "#/components/responses/500_error" */ -export default async (req, res) => { - const validated = await validator(AdminGetProductTagsParams, req.query) - +export default async (req: Request, res: Response) => { const tagService: ProductTagService = req.scope.resolve("productTagService") - - const listConfig: FindConfig = { - select: defaultAdminProductTagsFields as (keyof ProductTag)[], - relations: defaultAdminProductTagsRelations, - skip: validated.offset, - take: validated.limit, - } - - if (isDefined(validated.order)) { - let orderField = validated.order - if (validated.order.startsWith("-")) { - const [, field] = validated.order.split("-") - orderField = field - listConfig.order = { [field]: "DESC" } - } else { - listConfig.order = { [validated.order]: "ASC" } - } - - if (!allowedAdminProductTagsFields.includes(orderField)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Order field must be a valid product tag field" - ) - } - } - - const filterableFields = omit(validated, ["limit", "offset"]) + const { listConfig, filterableFields } = req + const { skip, take } = req.listConfig const [tags, count] = await tagService.listAndCount( - pickBy(filterableFields, identity), + filterableFields, listConfig ) res.status(200).json({ product_tags: tags, count, - offset: validated.offset, - limit: validated.limit, + offset: skip, + limit: take, }) } @@ -201,18 +165,17 @@ export class AdminGetProductTagsPaginationParams { offset = 0 } -// eslint-disable-next-line max-len export class AdminGetProductTagsParams extends AdminGetProductTagsPaginationParams { - @IsType([String, [String], StringComparisonOperator]) @IsOptional() + @IsType([String, [String], StringComparisonOperator]) id?: string | string[] | StringComparisonOperator @IsString() @IsOptional() q?: string - @IsType([String, [String], StringComparisonOperator]) @IsOptional() + @IsType([String, [String], StringComparisonOperator]) value?: string | string[] | StringComparisonOperator @IsType([DateComparisonOperator]) @@ -226,4 +189,8 @@ export class AdminGetProductTagsParams extends AdminGetProductTagsPaginationPara @IsString() @IsOptional() order?: string + + @IsString() + @IsOptional() + discount_condition_id?: string } diff --git a/packages/medusa/src/repositories/product-tag.ts b/packages/medusa/src/repositories/product-tag.ts index f2a5412578..fb3211b295 100644 --- a/packages/medusa/src/repositories/product-tag.ts +++ b/packages/medusa/src/repositories/product-tag.ts @@ -1,21 +1,38 @@ import { EntityRepository, In, Repository } from "typeorm" import { ProductTag } from "../models/product-tag" +import { ExtendedFindConfig } from "../types/common" type UpsertTagsInput = (Partial & { value: string })[] +type ProductTagSelector = Partial & { + q?: string + discount_condition_id?: string +} + +export type DefaultWithoutRelations = Omit< + ExtendedFindConfig, + "relations" +> + +export type FindWithoutRelationsOptions = DefaultWithoutRelations & { + where: DefaultWithoutRelations["where"] & { + discount_condition_id?: string + } +} + @EntityRepository(ProductTag) export class ProductTagRepository extends Repository { public async listTagsByUsage(count = 10): Promise { return await this.query( ` - SELECT id, COUNT(pts.product_tag_id) as usage_count, pt.value - FROM product_tag pt - LEFT JOIN product_tags pts ON pt.id = pts.product_tag_id - GROUP BY id - ORDER BY usage_count DESC - LIMIT $1 + SELECT id, COUNT(pts.product_tag_id) as usage_count, pt.value + FROM product_tag pt + LEFT JOIN product_tags pts ON pt.id = pts.product_tag_id + GROUP BY id + ORDER BY usage_count DESC + LIMIT $1 `, [count] ) @@ -47,4 +64,33 @@ export class ProductTagRepository extends Repository { return upsertedTags } + + async findAndCountByDiscountConditionId( + conditionId: string, + query: ExtendedFindConfig> + ) { + const qb = this.createQueryBuilder("pt") + + if (query?.select) { + qb.select(query.select.map((select) => `pt.${select}`)) + } + + if (query.skip) { + qb.skip(query.skip) + } + + if (query.take) { + qb.take(query.take) + } + + return await qb + .where(query.where) + .innerJoin( + "discount_condition_product_tag", + "dc_pt", + `dc_pt.product_tag_id = pt.id AND dc_pt.condition_id = :dcId`, + { dcId: conditionId } + ) + .getManyAndCount() + } } diff --git a/packages/medusa/src/services/product-tag.ts b/packages/medusa/src/services/product-tag.ts index be3b23296e..859bf39dc4 100644 --- a/packages/medusa/src/services/product-tag.ts +++ b/packages/medusa/src/services/product-tag.ts @@ -1,11 +1,10 @@ import { MedusaError } from "medusa-core-utils" -import { EntityManager, ILike, SelectQueryBuilder } from "typeorm" +import { EntityManager, ILike } from "typeorm" import { ProductTag } from "../models" import { ProductTagRepository } from "../repositories/product-tag" import { FindConfig } from "../types/common" -import { FilterableProductTagProps } from "../types/product" import { TransactionBaseService } from "../interfaces" -import { buildQuery } from "../utils" +import { buildQuery, isString } from "../utils" type ProductTagConstructorProps = { manager: EntityManager @@ -70,13 +69,14 @@ class ProductTagService extends TransactionBaseService { * @return the result of the find operation */ async list( - selector: FilterableProductTagProps = {}, + selector: Partial & { + q?: string + discount_condition_id?: string + } = {}, config: FindConfig = { skip: 0, take: 20 } ): Promise { - const tagRepo = this.manager_.getCustomRepository(this.tagRepo_) - - const query = buildQuery(selector, config) - return await tagRepo.find(query) + const [tags] = await this.listAndCount(selector, config) + return tags } /** @@ -86,13 +86,16 @@ class ProductTagService extends TransactionBaseService { * @return the result of the find operation */ async listAndCount( - selector: FilterableProductTagProps = {}, + selector: Partial & { + q?: string + discount_condition_id?: string + } = {}, config: FindConfig = { skip: 0, take: 20 } ): Promise<[ProductTag[], number]> { const tagRepo = this.manager_.getCustomRepository(this.tagRepo_) - let q: string | undefined = undefined - if ("q" in selector) { + let q: string | undefined + if (isString(selector.q)) { q = selector.q delete selector.q } @@ -100,13 +103,16 @@ class ProductTagService extends TransactionBaseService { const query = buildQuery(selector, config) if (q) { - const where = query.where + query.where.value = ILike(`%${q}%`) + } - delete where.value - - query.where = (qb: SelectQueryBuilder): void => { - qb.where(where).andWhere([{ value: ILike(`%${q}%`) }]) - } + if (query.where.discount_condition_id) { + const discountConditionId = query.where.discount_condition_id as string + delete query.where.discount_condition_id + return await tagRepo.findAndCountByDiscountConditionId( + discountConditionId, + query + ) } return await tagRepo.findAndCount(query) diff --git a/packages/medusa/src/types/product.ts b/packages/medusa/src/types/product.ts index 3fcd2f517e..ebc184bf92 100644 --- a/packages/medusa/src/types/product.ts +++ b/packages/medusa/src/types/product.ts @@ -12,11 +12,7 @@ import { Product, ProductOptionValue, ProductStatus } from "../models" import { FeatureFlagDecorators } from "../utils/feature-flag-decorators" import { optionalBooleanMapper } from "../utils/validators/is-boolean" import { IsType } from "../utils/validators/is-type" -import { - DateComparisonOperator, - FindConfig, - StringComparisonOperator, -} from "./common" +import { DateComparisonOperator, FindConfig } from "./common" import { PriceListLoadConfig } from "./price-list" /** @@ -87,28 +83,6 @@ export class FilterableProductProps { deleted_at?: DateComparisonOperator } -export class FilterableProductTagProps { - @IsOptional() - @IsType([String, [String], StringComparisonOperator]) - id?: string | string[] | StringComparisonOperator - - @IsOptional() - @IsType([String, [String], StringComparisonOperator]) - value?: string | string[] | StringComparisonOperator - - @IsOptional() - @IsType([DateComparisonOperator]) - created_at?: DateComparisonOperator - - @IsOptional() - @IsType([DateComparisonOperator]) - updated_at?: DateComparisonOperator - - @IsString() - @IsOptional() - q?: string -} - /** * Service Level DTOs */