From 0929c4f4573cf318ed3437317edd43d694c233bf Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Tue, 4 Jun 2024 10:56:22 +0200 Subject: [PATCH] Feat: Add product tag endpoints, move tests to HTTP folder (#7591) * chore: Move product type tests to HTTP folder * feat: Add product tags endpoints and move tests to HTTP folder --- .../api/__tests__/admin/product-tag.js | 178 ------------------ .../api/__tests__/admin/product-type.js | 167 ---------------- .../product-tag/admin/product-tag.spec.ts | 69 +++++++ .../product-type/admin/product-type.spec.ts | 73 +++++++ .../src/product/steps/create-product-tags.ts | 30 +++ .../src/product/steps/delete-product-tags.ts | 27 +++ .../core-flows/src/product/steps/index.ts | 3 + .../src/product/steps/update-product-tags.ts | 42 +++++ .../product/workflows/create-product-tags.ts | 15 ++ .../product/workflows/delete-product-tags.ts | 12 ++ .../core-flows/src/product/workflows/index.ts | 3 + .../product/workflows/update-product-tags.ts | 20 ++ packages/core/types/src/product/service.ts | 174 ++++++++++++++--- .../src/api/admin/product-tags/[id]/route.ts | 66 +++++++ .../src/api/admin/product-tags/middlewares.ts | 61 ++++++ .../api/admin/product-tags/query-config.ts | 17 ++ .../src/api/admin/product-tags/route.ts | 51 +++++ .../src/api/admin/product-tags/validators.ts | 46 +++++ packages/medusa/src/api/middlewares.ts | 2 + .../medusa/src/api/utils/refetch-entity.ts | 2 +- .../product-tags.spec.ts | 20 +- .../__tests__/services/product-tag/index.ts | 7 +- .../src/services/product-module-service.ts | 115 +++++++++-- packages/modules/product/src/types/index.ts | 4 + 24 files changed, 805 insertions(+), 399 deletions(-) delete mode 100644 integration-tests/api/__tests__/admin/product-tag.js delete mode 100644 integration-tests/api/__tests__/admin/product-type.js create mode 100644 integration-tests/http/__tests__/product-tag/admin/product-tag.spec.ts create mode 100644 integration-tests/http/__tests__/product-type/admin/product-type.spec.ts create mode 100644 packages/core/core-flows/src/product/steps/create-product-tags.ts create mode 100644 packages/core/core-flows/src/product/steps/delete-product-tags.ts create mode 100644 packages/core/core-flows/src/product/steps/update-product-tags.ts create mode 100644 packages/core/core-flows/src/product/workflows/create-product-tags.ts create mode 100644 packages/core/core-flows/src/product/workflows/delete-product-tags.ts create mode 100644 packages/core/core-flows/src/product/workflows/update-product-tags.ts create mode 100644 packages/medusa/src/api/admin/product-tags/[id]/route.ts create mode 100644 packages/medusa/src/api/admin/product-tags/middlewares.ts create mode 100644 packages/medusa/src/api/admin/product-tags/query-config.ts create mode 100644 packages/medusa/src/api/admin/product-tags/route.ts create mode 100644 packages/medusa/src/api/admin/product-tags/validators.ts diff --git a/integration-tests/api/__tests__/admin/product-tag.js b/integration-tests/api/__tests__/admin/product-tag.js deleted file mode 100644 index 667caeae75..0000000000 --- a/integration-tests/api/__tests__/admin/product-tag.js +++ /dev/null @@ -1,178 +0,0 @@ -const path = require("path") - -const { IdMap } = require("medusa-test-utils") - -const setupServer = require("../../../environment-helpers/setup-server") -const { useApi } = require("../../../environment-helpers/use-api") -const { initDb, useDb } = require("../../../environment-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: { - "x-medusa-access-token": "test_token", - }, -} - -describe("/admin/product-tags", () => { - let medusaProcess - let dbConnection - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) - dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd }) - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - medusaProcess.kill() - }) - - describe("GET /admin/product-tags", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - await productSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("returns a list of product tags", async () => { - const api = useApi() - - const res = await api - .get("/admin/product-tags", adminReqConfig) - .catch((err) => { - console.log(err) - }) - - expect(res.status).toEqual(200) - - const tagMatch = { - created_at: expect.any(String), - updated_at: expect.any(String), - } - - expect(res.data.product_tags).toMatchSnapshot([ - tagMatch, - tagMatch, - tagMatch, - ]) - }) - - it("returns a list of product tags matching free text search param", async () => { - const api = useApi() - - const res = await api - .get("/admin/product-tags?q=123", adminReqConfig) - .catch((err) => { - console.log(err) - }) - - expect(res.status).toEqual(200) - - const tagMatch = { - created_at: expect.any(String), - updated_at: expect.any(String), - } - - expect(res.data.product_tags.length).toEqual(3) - expect(res.data.product_tags.map((pt) => pt.value)).toEqual( - expect.arrayContaining(["123", "1235", "1234"]) - ) - - expect(res.data.product_tags).toMatchSnapshot([ - tagMatch, - tagMatch, - 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/integration-tests/api/__tests__/admin/product-type.js b/integration-tests/api/__tests__/admin/product-type.js deleted file mode 100644 index f7ed438b05..0000000000 --- a/integration-tests/api/__tests__/admin/product-type.js +++ /dev/null @@ -1,167 +0,0 @@ -const path = require("path") - -const { IdMap } = require("medusa-test-utils") - -const setupServer = require("../../../environment-helpers/setup-server") -const { useApi } = require("../../../environment-helpers/use-api") -const { initDb, useDb } = require("../../../environment-helpers/use-db") - -const adminSeeder = require("../../../helpers/admin-seeder") -const productSeeder = require("../../../helpers/product-seeder") -const { - DiscountRuleType, - AllocationType, - DiscountConditionType, - DiscountConditionOperator, -} = require("@medusajs/medusa") -const { simpleDiscountFactory } = require("../../../factories") - -jest.setTimeout(50000) - -const adminReqConfig = { - headers: { - "x-medusa-access-token": "test_token", - }, -} - -describe("/admin/product-types", () => { - let medusaProcess - let dbConnection - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) - dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd }) - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - medusaProcess.kill() - }) - - describe("GET /admin/product-types", () => { - beforeEach(async () => { - await productSeeder(dbConnection) - await adminSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("returns a list of product types", async () => { - const api = useApi() - - const res = await api.get("/admin/product-types", adminReqConfig) - - expect(res.status).toEqual(200) - - const typeMatch = { - created_at: expect.any(String), - updated_at: expect.any(String), - } - - expect(res.data.product_types).toMatchSnapshot([typeMatch, typeMatch]) - }) - - it("returns a list of product types matching free text search param", async () => { - const api = useApi() - - const res = await api.get( - "/admin/product-types?q=test-type-new", - adminReqConfig - ) - - expect(res.status).toEqual(200) - - const typeMatch = { - created_at: expect.any(String), - updated_at: expect.any(String), - } - - // The value of the type should match the search param - expect(res.data.product_types.map((pt) => pt.value)).toEqual([ - "test-type-new", - ]) - - // Should only return one type as there is only one match to the search param - expect(res.data.product_types).toMatchSnapshot([typeMatch]) - }) - - it("returns a list of product type filtered by discount condition id", async () => { - const api = useApi() - - const resTypes = await api.get("/admin/product-types", adminReqConfig) - - const type1 = resTypes.data.product_types[0] - const type2 = resTypes.data.product_types[1] - - const buildDiscountData = (code, conditionId, types) => { - return { - code, - rule: { - type: DiscountRuleType.PERCENTAGE, - value: 10, - allocation: AllocationType.TOTAL, - conditions: [ - { - id: conditionId, - type: DiscountConditionType.PRODUCT_TYPES, - operator: DiscountConditionOperator.IN, - product_types: types, - }, - ], - }, - } - } - - const discountConditionId = IdMap.getId("discount-condition-type-1") - await simpleDiscountFactory( - dbConnection, - buildDiscountData("code-1", discountConditionId, [type1.id]) - ) - - const discountConditionId2 = IdMap.getId("discount-condition-type-2") - await simpleDiscountFactory( - dbConnection, - buildDiscountData("code-2", discountConditionId2, [type2.id]) - ) - - let res = await api.get( - `/admin/product-types?discount_condition_id=${discountConditionId}`, - adminReqConfig - ) - - expect(res.status).toEqual(200) - expect(res.data.product_types).toHaveLength(1) - expect(res.data.product_types).toEqual( - expect.arrayContaining([expect.objectContaining({ id: type1.id })]) - ) - - res = await api.get( - `/admin/product-types?discount_condition_id=${discountConditionId2}`, - adminReqConfig - ) - - expect(res.status).toEqual(200) - expect(res.data.product_types).toHaveLength(1) - expect(res.data.product_types).toEqual( - expect.arrayContaining([expect.objectContaining({ id: type2.id })]) - ) - - res = await api.get(`/admin/product-types`, adminReqConfig) - - expect(res.status).toEqual(200) - expect(res.data.product_types).toHaveLength(2) - expect(res.data.product_types).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: type1.id }), - expect.objectContaining({ id: type2.id }), - ]) - ) - }) - }) -}) diff --git a/integration-tests/http/__tests__/product-tag/admin/product-tag.spec.ts b/integration-tests/http/__tests__/product-tag/admin/product-tag.spec.ts new file mode 100644 index 0000000000..a6504a9cdb --- /dev/null +++ b/integration-tests/http/__tests__/product-tag/admin/product-tag.spec.ts @@ -0,0 +1,69 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + createAdminUser, + adminHeaders, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + env: {}, + testSuite: ({ dbConnection, getContainer, api }) => { + let tag1 + let tag2 + + beforeEach(async () => { + const container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + + 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 /admin/product-tags", () => { + it("returns a list of product tags", async () => { + const res = await api.get("/admin/product-tags", adminHeaders) + + 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" })]) + ) + }) + }) + // BREAKING: Removed a test that filtered tags by discount condition id, which is no longer supported + }, +}) diff --git a/integration-tests/http/__tests__/product-type/admin/product-type.spec.ts b/integration-tests/http/__tests__/product-type/admin/product-type.spec.ts new file mode 100644 index 0000000000..654b3d60a3 --- /dev/null +++ b/integration-tests/http/__tests__/product-type/admin/product-type.spec.ts @@ -0,0 +1,73 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { + createAdminUser, + adminHeaders, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + env: {}, + testSuite: ({ dbConnection, getContainer, api }) => { + let type1 + let type2 + + beforeEach(async () => { + const container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + + type1 = ( + await api.post( + "/admin/product-types", + { + value: "test1", + }, + adminHeaders + ) + ).data.product_type + + type2 = ( + await api.post( + "/admin/product-types", + { + value: "test2", + }, + adminHeaders + ) + ).data.product_type + }) + + describe("/admin/product-types", () => { + it("returns a list of product types", async () => { + const res = await api.get("/admin/product-types", adminHeaders) + + 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("/admin/product-types?q=test1", adminHeaders) + + expect(res.status).toEqual(200) + + // The value of the type should match the search param + expect(res.data.product_types).toEqual([ + expect.objectContaining({ + value: "test1", + }), + ]) + }) + + // BREAKING: Removed a test around filtering based on discount condition id, which is no longer supported + }) + }, +}) diff --git a/packages/core/core-flows/src/product/steps/create-product-tags.ts b/packages/core/core-flows/src/product/steps/create-product-tags.ts new file mode 100644 index 0000000000..46e60f5fbb --- /dev/null +++ b/packages/core/core-flows/src/product/steps/create-product-tags.ts @@ -0,0 +1,30 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IProductModuleService, ProductTypes } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const createProductTagsStepId = "create-product-tags" +export const createProductTagsStep = createStep( + createProductTagsStepId, + async (data: ProductTypes.CreateProductTagDTO[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + const created = await service.createTags(data) + return new StepResponse( + created, + created.map((productTag) => productTag.id) + ) + }, + async (createdIds, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + await service.deleteTags(createdIds) + } +) diff --git a/packages/core/core-flows/src/product/steps/delete-product-tags.ts b/packages/core/core-flows/src/product/steps/delete-product-tags.ts new file mode 100644 index 0000000000..44565c40ee --- /dev/null +++ b/packages/core/core-flows/src/product/steps/delete-product-tags.ts @@ -0,0 +1,27 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IProductModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const deleteProductTagsStepId = "delete-product-tags" +export const deleteProductTagsStep = createStep( + deleteProductTagsStepId, + async (ids: string[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + await service.softDeleteTags(ids) + return new StepResponse(void 0, ids) + }, + async (prevIds, { container }) => { + if (!prevIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + await service.restoreTags(prevIds) + } +) diff --git a/packages/core/core-flows/src/product/steps/index.ts b/packages/core/core-flows/src/product/steps/index.ts index be84ce8e89..f9bc342926 100644 --- a/packages/core/core-flows/src/product/steps/index.ts +++ b/packages/core/core-flows/src/product/steps/index.ts @@ -16,3 +16,6 @@ export * from "./batch-link-products-collection" export * from "./create-product-types" export * from "./update-product-types" export * from "./delete-product-types" +export * from "./create-product-tags" +export * from "./update-product-tags" +export * from "./delete-product-tags" diff --git a/packages/core/core-flows/src/product/steps/update-product-tags.ts b/packages/core/core-flows/src/product/steps/update-product-tags.ts new file mode 100644 index 0000000000..b4bef994da --- /dev/null +++ b/packages/core/core-flows/src/product/steps/update-product-tags.ts @@ -0,0 +1,42 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IProductModuleService, ProductTypes } from "@medusajs/types" +import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type UpdateProductTagsStepInput = { + selector: ProductTypes.FilterableProductTagProps + update: ProductTypes.UpdateProductTagDTO +} + +export const updateProductTagsStepId = "update-product-tags" +export const updateProductTagsStep = createStep( + updateProductTagsStepId, + async (data: UpdateProductTagsStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + + const prevData = await service.listTags(data.selector, { + select: selects, + relations, + }) + + const productTags = await service.updateTags(data.selector, data.update) + return new StepResponse(productTags, prevData) + }, + async (prevData, { container }) => { + if (!prevData?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + await service.upsertTags(prevData) + } +) diff --git a/packages/core/core-flows/src/product/workflows/create-product-tags.ts b/packages/core/core-flows/src/product/workflows/create-product-tags.ts new file mode 100644 index 0000000000..e573f7341a --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/create-product-tags.ts @@ -0,0 +1,15 @@ +import { ProductTypes } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createProductTagsStep } from "../steps" + +type WorkflowInput = { product_tags: ProductTypes.CreateProductTagDTO[] } + +export const createProductTagsWorkflowId = "create-product-tags" +export const createProductTagsWorkflow = createWorkflow( + createProductTagsWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return createProductTagsStep(input.product_tags) + } +) diff --git a/packages/core/core-flows/src/product/workflows/delete-product-tags.ts b/packages/core/core-flows/src/product/workflows/delete-product-tags.ts new file mode 100644 index 0000000000..23d079bc37 --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/delete-product-tags.ts @@ -0,0 +1,12 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteProductTagsStep } from "../steps" + +type WorkflowInput = { ids: string[] } + +export const deleteProductTagsWorkflowId = "delete-product-tags" +export const deleteProductTagsWorkflow = createWorkflow( + deleteProductTagsWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteProductTagsStep(input.ids) + } +) diff --git a/packages/core/core-flows/src/product/workflows/index.ts b/packages/core/core-flows/src/product/workflows/index.ts index fbe3c61b37..43cc38dc6e 100644 --- a/packages/core/core-flows/src/product/workflows/index.ts +++ b/packages/core/core-flows/src/product/workflows/index.ts @@ -5,15 +5,18 @@ export * from "./batch-products-in-category" export * from "./create-collections" export * from "./create-product-options" export * from "./create-product-types" +export * from "./create-product-tags" export * from "./create-product-variants" export * from "./create-products" export * from "./delete-collections" export * from "./delete-product-options" export * from "./delete-product-types" +export * from "./delete-product-tags" export * from "./delete-product-variants" export * from "./delete-products" export * from "./update-collections" export * from "./update-product-options" export * from "./update-product-types" +export * from "./update-product-tags" export * from "./update-product-variants" export * from "./update-products" diff --git a/packages/core/core-flows/src/product/workflows/update-product-tags.ts b/packages/core/core-flows/src/product/workflows/update-product-tags.ts new file mode 100644 index 0000000000..45fbd1ed67 --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/update-product-tags.ts @@ -0,0 +1,20 @@ +import { ProductTypes } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateProductTagsStep } from "../steps" + +type UpdateProductTagsStepInput = { + selector: ProductTypes.FilterableProductTypeProps + update: ProductTypes.UpdateProductTypeDTO +} + +type WorkflowInput = UpdateProductTagsStepInput + +export const updateProductTagsWorkflowId = "update-product-tags" +export const updateProductTagsWorkflow = createWorkflow( + updateProductTagsWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return updateProductTagsStep(input) + } +) diff --git a/packages/core/types/src/product/service.ts b/packages/core/types/src/product/service.ts index 31850e0d92..5e45c8cdee 100644 --- a/packages/core/types/src/product/service.ts +++ b/packages/core/types/src/product/service.ts @@ -31,6 +31,7 @@ import { UpsertProductCollectionDTO, UpsertProductDTO, UpsertProductOptionDTO, + UpsertProductTagDTO, UpsertProductTypeDTO, UpsertProductVariantDTO, } from "./common" @@ -512,19 +513,16 @@ export interface IProductModuleService extends IModuleService { ): Promise<[ProductTagDTO[], number]> /** - * This method is used to create product tags. + * This method is used to create a product tag. * - * @param {CreateProductTagDTO[]} data - The product tags to create. + * @param {CreateProductTagDTO[]} data - The product tags to be created. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. - * @returns {Promise} The list of product tags. + * @return {Promise} The list of created product tags. * * @example - * const tags = await productModuleService.createTags([ + * const productTags = await productModuleService.createTags([ * { - * value: "Clothes", - * }, - * { - * value: "Accessories", + * value: "digital", * }, * ]) */ @@ -534,29 +532,111 @@ export interface IProductModuleService extends IModuleService { ): Promise /** - * This method is used to update existing product tags. + * This method is used to create a product tag. * - * @param {UpdateProductTagDTO[]} data - The product tags to be updated, each having the attributes that should be updated in a product tag. + * @param {CreateProductTagDTO} data - The product tag to be created. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. - * @returns {Promise} The list of updated product tags. + * @returns {Promise} The created product tag. * * @example - * const productTags = await productModule.updateTags([ + * const productTag = await productModuleService.createTags({ + * value: "digital", + * }) + * + */ + createTags( + data: CreateProductTagDTO, + sharedContext?: Context + ): Promise + + /** + * This method updates existing tags, or creates new ones if they don't exist. + * + * @param {UpsertProductTagDTO[]} data - The attributes to update or create for each tag. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated and created tags. + * + * @example + * const productTags = await productModuleService.upsertTags([ * { - * id, - * value - * } + * id: "ptag_123", + * metadata: { + * test: true, + * }, + * }, + * { + * value: "Digital", + * }, * ]) + */ + upsertTags( + data: UpsertProductTagDTO[], + sharedContext?: Context + ): Promise + + /** + * This method updates an existing tag, or creates a new one if it doesn't exist. * - * @ignore + * @param {UpsertProductTagDTO} data - The attributes to update or create for the tag. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated or created tag. * - * @privateRemarks - * This method needs an update as it doesn't allow passing an ID of the tag to update - * So, for now, we've added the `@\ignore` tag to not show it in the generated docs. - * Once fixed, the `@\ignore` tag (and this comment) can be removed safely. + * @example + * const productTag = await productModuleService.upsertTags({ + * id: "ptag_123", + * metadata: { + * test: true, + * }, + * }) + */ + upsertTags( + data: UpsertProductTagDTO, + sharedContext?: Context + ): Promise + + /** + * This method is used to update a tag. + * + * @param {string} id - The ID of the tag to be updated. + * @param {UpdateProductTagDTO} data - The attributes of the tag to be updated + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated tag. + * + * @example + * const productTag = await productModuleService.updateTags( + * "ptag_123", + * { + * value: "Digital", + * } + * ) */ updateTags( - data: UpdateProductTagDTO[], + id: string, + data: UpdateProductTagDTO, + sharedContext?: Context + ): Promise + + /** + * This method is used to update a list of tags matching the specified filters. + * + * @param {FilterableProductTagProps} selector - The filters specifying which tags to update. + * @param {UpdateProductTagDTO} data - The attributes to be updated on the selected tags + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated tags. + * + * @example + * const productTags = await productModuleService.updateTags( + * { + * id: ["ptag_123", "ptag_321"], + * }, + * { + * value: "Digital", + * } + * ) + */ + updateTags( + selector: FilterableProductTagProps, + data: UpdateProductTagDTO, sharedContext?: Context ): Promise @@ -575,6 +655,58 @@ export interface IProductModuleService extends IModuleService { */ deleteTags(productTagIds: string[], sharedContext?: Context): Promise + /** + * This method is used to delete tags. Unlike the {@link delete} method, this method won't completely remove the tag. It can still be accessed or retrieved using methods like {@link retrieve} if you pass the `withDeleted` property to the `config` object parameter. + * + * The soft-deleted tags can be restored using the {@link restore} method. + * + * @param {string[]} tagIds - The IDs of the tags to soft-delete. + * @param {SoftDeleteReturn} config - + * Configurations determining which relations to soft delete along with the each of the tags. You can pass to its `returnLinkableKeys` + * property any of the tag's relation attribute names. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise | void>} + * An object that includes the IDs of related records that were also soft deleted. The object's keys are the ID attribute names of the tag entity's relations, and its value is an array of strings, each being the ID of a record associated with the tag through this relation. + * + * If there are no related records, the promise resolved to `void`. + * + * @example + * await productModuleService.softDeleteTags([ + * "ptag_123", + * "ptag_321", + * ]) + */ + softDeleteTags( + tagIds: string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + /** + * This method is used to restore tags which were deleted using the {@link softDelete} method. + * + * @param {string[]} tagIds - The IDs of the tags to restore. + * @param {RestoreReturn} config - + * Configurations determining which relations to restore along with each of the tags. You can pass to its `returnLinkableKeys` + * property any of the tag's relation attribute names. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise | void>} + * An object that includes the IDs of related records that were restored. The object's keys are the ID attribute names of the tag entity's relations, and its value is an array of strings, each being the ID of the record associated with the tag through this relation. + * + * If there are no related records that were restored, the promise resolved to `void`. + * + * @example + * await productModuleService.restoreTags([ + * "ptag_123", + * "ptag_321", + * ]) + */ + restoreTags( + tagIds: string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> + /** * This method is used to retrieve a product type by its ID. * diff --git a/packages/medusa/src/api/admin/product-tags/[id]/route.ts b/packages/medusa/src/api/admin/product-tags/[id]/route.ts new file mode 100644 index 0000000000..2e0f46a7a1 --- /dev/null +++ b/packages/medusa/src/api/admin/product-tags/[id]/route.ts @@ -0,0 +1,66 @@ +import { + deleteProductTagsWorkflow, + updateProductTagsWorkflow, +} from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../types/routing" + +import { + AdminGetProductTagParamsType, + AdminUpdateProductTagType, +} from "../validators" +import { refetchEntity } from "../../../utils/refetch-entity" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const productTag = await refetchEntity( + "product_tag", + req.params.id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ product_tag: productTag }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { result } = await updateProductTagsWorkflow(req.scope).run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody, + }, + }) + + const productTag = await refetchEntity( + "product_tag", + result[0].id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ product_tag: productTag }) +} + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + + await deleteProductTagsWorkflow(req.scope).run({ + input: { ids: [id] }, + }) + + res.status(200).json({ + id, + object: "product_tag", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/admin/product-tags/middlewares.ts b/packages/medusa/src/api/admin/product-tags/middlewares.ts new file mode 100644 index 0000000000..6d9d7a7b49 --- /dev/null +++ b/packages/medusa/src/api/admin/product-tags/middlewares.ts @@ -0,0 +1,61 @@ +import * as QueryConfig from "./query-config" +import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" +import { validateAndTransformQuery } from "../../utils/validate-query" +import { + AdminCreateProductTag, + AdminGetProductTagParams, + AdminGetProductTagsParams, + AdminUpdateProductTag, +} from "./validators" +import { validateAndTransformBody } from "../../utils/validate-body" + +export const adminProductTagRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/product-tags", + middlewares: [ + validateAndTransformQuery( + AdminGetProductTagsParams, + QueryConfig.listProductTagsTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/product-tags/:id", + middlewares: [ + validateAndTransformQuery( + AdminGetProductTagParams, + QueryConfig.retrieveProductTagTransformQueryConfig + ), + ], + }, + // Create/update/delete methods are new in v2 + { + method: ["POST"], + matcher: "/admin/product-tags", + middlewares: [ + validateAndTransformBody(AdminCreateProductTag), + validateAndTransformQuery( + AdminGetProductTagParams, + QueryConfig.retrieveProductTagTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/product-tags/:id", + middlewares: [ + validateAndTransformBody(AdminUpdateProductTag), + validateAndTransformQuery( + AdminGetProductTagParams, + QueryConfig.retrieveProductTagTransformQueryConfig + ), + ], + }, + { + method: ["DELETE"], + matcher: "/admin/product-tags/:id", + middlewares: [], + }, +] diff --git a/packages/medusa/src/api/admin/product-tags/query-config.ts b/packages/medusa/src/api/admin/product-tags/query-config.ts new file mode 100644 index 0000000000..79b5ebef5e --- /dev/null +++ b/packages/medusa/src/api/admin/product-tags/query-config.ts @@ -0,0 +1,17 @@ +export const defaultAdminProductTagFields = [ + "id", + "value", + "created_at", + "updated_at", +] + +export const retrieveProductTagTransformQueryConfig = { + defaults: defaultAdminProductTagFields, + isList: false, +} + +export const listProductTagsTransformQueryConfig = { + ...retrieveProductTagTransformQueryConfig, + defaultLimit: 20, + isList: true, +} diff --git a/packages/medusa/src/api/admin/product-tags/route.ts b/packages/medusa/src/api/admin/product-tags/route.ts new file mode 100644 index 0000000000..eac4e1e4d0 --- /dev/null +++ b/packages/medusa/src/api/admin/product-tags/route.ts @@ -0,0 +1,51 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../types/routing" + +import { createProductTagsWorkflow } from "@medusajs/core-flows" +import { + AdminCreateProductTagType, + AdminGetProductTagsParamsType, +} from "./validators" +import { refetchEntities, refetchEntity } from "../../utils/refetch-entity" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { rows: product_tags, metadata } = await refetchEntities( + "product_tag", + req.filterableFields, + req.scope, + req.remoteQueryConfig.fields, + req.remoteQueryConfig.pagination + ) + + res.json({ + product_tags: product_tags, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const input = [req.validatedBody] + + const { result } = await createProductTagsWorkflow(req.scope).run({ + input: { product_tags: input }, + }) + + const productTag = await refetchEntity( + "product_tag", + result[0].id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ product_tag: productTag }) +} diff --git a/packages/medusa/src/api/admin/product-tags/validators.ts b/packages/medusa/src/api/admin/product-tags/validators.ts new file mode 100644 index 0000000000..33ad6ceb59 --- /dev/null +++ b/packages/medusa/src/api/admin/product-tags/validators.ts @@ -0,0 +1,46 @@ +import { z } from "zod" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../utils/validators" + +export type AdminGetProductTagParamsType = z.infer< + typeof AdminGetProductTagParams +> +export const AdminGetProductTagParams = createSelectParams() + +export type AdminGetProductTagsParamsType = z.infer< + typeof AdminGetProductTagsParams +> +export const AdminGetProductTagsParams = createFindParams({ + limit: 20, + offset: 0, +}).merge( + 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(), + $and: z.lazy(() => AdminGetProductTagsParams.array()).optional(), + $or: z.lazy(() => AdminGetProductTagsParams.array()).optional(), + }) +) + +export type AdminCreateProductTagType = z.infer +export const AdminCreateProductTag = z + .object({ + value: z.string(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .strict() + +export type AdminUpdateProductTagType = z.infer +export const AdminUpdateProductTag = z + .object({ + value: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .strict() diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index 4f5ca171d2..d1928d954d 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -17,6 +17,7 @@ import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middleware import { adminPricingRoutesMiddlewares } from "./admin/pricing/middlewares" import { adminProductCategoryRoutesMiddlewares } from "./admin/product-categories/middlewares" import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares" +import { adminProductTagRoutesMiddlewares } from "./admin/product-tags/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" import { adminRegionRoutesMiddlewares } from "./admin/regions/middlewares" @@ -86,6 +87,7 @@ export const config: MiddlewaresConfig = { ...adminSalesChannelRoutesMiddlewares, ...adminStockLocationRoutesMiddlewares, ...adminProductTypeRoutesMiddlewares, + ...adminProductTagRoutesMiddlewares, ...adminUploadRoutesMiddlewares, ...adminFulfillmentSetsRoutesMiddlewares, ...adminOrderRoutesMiddlewares, diff --git a/packages/medusa/src/api/utils/refetch-entity.ts b/packages/medusa/src/api/utils/refetch-entity.ts index ac8bbc5a18..a071d2d070 100644 --- a/packages/medusa/src/api/utils/refetch-entity.ts +++ b/packages/medusa/src/api/utils/refetch-entity.ts @@ -24,7 +24,7 @@ export const refetchEntities = async ( delete filters.context } - let variables = { filters, ...context, ...pagination } + const variables = { filters, ...context, ...pagination } const queryObject = remoteQueryObjectFromString({ entryPoint, diff --git a/packages/modules/product/integration-tests/__tests__/services/product-module-service/product-tags.spec.ts b/packages/modules/product/integration-tests/__tests__/services/product-module-service/product-tags.spec.ts index 4ba5f98a03..e8c6583e83 100644 --- a/packages/modules/product/integration-tests/__tests__/services/product-module-service/product-tags.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/services/product-module-service/product-tags.spec.ts @@ -251,12 +251,9 @@ moduleIntegrationTestRunner({ const tagId = "tag-1" it("should update the value of the tag successfully", async () => { - await service.updateTags([ - { - id: tagId, - value: "UK", - }, - ]) + await service.updateTags(tagId, { + value: "UK", + }) const productTag = await service.retrieveTag(tagId) @@ -267,18 +264,15 @@ moduleIntegrationTestRunner({ let error try { - await service.updateTags([ - { - id: "does-not-exist", - value: "UK", - }, - ]) + await service.updateTags("does-not-exist", { + value: "UK", + }) } catch (e) { error = e } expect(error.message).toEqual( - 'ProductTag with id "does-not-exist" not found' + "ProductTag with id: does-not-exist was not found" ) }) }) diff --git a/packages/modules/product/integration-tests/__tests__/services/product-tag/index.ts b/packages/modules/product/integration-tests/__tests__/services/product-tag/index.ts index 3844f0f7cb..0b0ba06d50 100644 --- a/packages/modules/product/integration-tests/__tests__/services/product-tag/index.ts +++ b/packages/modules/product/integration-tests/__tests__/services/product-tag/index.ts @@ -1,8 +1,7 @@ import { Modules } from "@medusajs/modules-sdk" -import { IProductModuleService } from "@medusajs/types" +import { IProductModuleService, ModulesSdkTypes } from "@medusajs/types" import { ProductStatus } from "@medusajs/utils" -import { Product } from "@models" -import { ProductTagService } from "@services" +import { Product, ProductTag } from "@models" import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" import { createProductAndTags } from "../../../__fixtures__/product" @@ -16,7 +15,7 @@ moduleIntegrationTestRunner({ }: SuiteOptions) => { describe("ProductTag Service", () => { let data!: Product[] - let service: ProductTagService + let service: ModulesSdkTypes.InternalModuleService beforeEach(() => { service = medusaApp.modules["productService"].productTagService_ diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 2747597f91..0e06eb1b7b 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -47,6 +47,7 @@ import { UpdateProductInput, UpdateProductOptionInput, UpdateProductVariantInput, + UpdateTagInput, UpdateTypeInput, } from "../types" import { entityNameToLinkableKeysMap, joinerConfig } from "./../joiner-config" @@ -384,30 +385,114 @@ export default class ProductModuleService< ) } - @InjectTransactionManager("baseRepository_") - async createTags( + createTags( data: ProductTypes.CreateProductTagDTO[], - @MedusaContext() sharedContext: Context = {} - ): Promise { - const productTags = await this.productTagService_.create( - data, - sharedContext - ) + sharedContext?: Context + ): Promise + createTags( + data: ProductTypes.CreateProductTagDTO, + sharedContext?: Context + ): Promise - return await this.baseRepository_.serialize(productTags) + @InjectManager("baseRepository_") + async createTags( + data: ProductTypes.CreateProductTagDTO[] | ProductTypes.CreateProductTagDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const tags = await this.productTagService_.create(input, sharedContext) + + const createdTags = await this.baseRepository_.serialize< + ProductTypes.ProductTagDTO[] + >(tags) + + return Array.isArray(data) ? createdTags : createdTags[0] } + async upsertTags( + data: ProductTypes.UpsertProductTagDTO[], + sharedContext?: Context + ): Promise + async upsertTags( + data: ProductTypes.UpsertProductTagDTO, + sharedContext?: Context + ): Promise + @InjectTransactionManager("baseRepository_") - async updateTags( - data: ProductTypes.UpdateProductTagDTO[], + async upsertTags( + data: ProductTypes.UpsertProductTagDTO[] | ProductTypes.UpsertProductTagDTO, @MedusaContext() sharedContext: Context = {} - ): Promise { - const productTags = await this.productTagService_.update( - data, + ): Promise { + const input = Array.isArray(data) ? data : [data] + const forUpdate = input.filter((tag): tag is UpdateTagInput => !!tag.id) + const forCreate = input.filter( + (tag): tag is ProductTypes.CreateProductTagDTO => !tag.id + ) + + let created: ProductTag[] = [] + let updated: ProductTag[] = [] + + if (forCreate.length) { + created = await this.productTagService_.create(forCreate, sharedContext) + } + if (forUpdate.length) { + updated = await this.productTagService_.update(forUpdate, sharedContext) + } + + const result = [...created, ...updated] + const allTags = await this.baseRepository_.serialize< + ProductTypes.ProductTagDTO[] | ProductTypes.ProductTagDTO + >(result) + + return Array.isArray(data) ? allTags : allTags[0] + } + + updateTags( + id: string, + data: ProductTypes.UpdateProductTagDTO, + sharedContext?: Context + ): Promise + updateTags( + selector: ProductTypes.FilterableProductTagProps, + data: ProductTypes.UpdateProductTagDTO, + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async updateTags( + idOrSelector: string | ProductTypes.FilterableProductTagProps, + data: ProductTypes.UpdateProductTagDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { + let normalizedInput: UpdateTagInput[] = [] + if (isString(idOrSelector)) { + // Check if the tag exists in the first place + await this.productTagService_.retrieve(idOrSelector, {}, sharedContext) + normalizedInput = [{ id: idOrSelector, ...data }] + } else { + const tags = await this.productTagService_.list( + idOrSelector, + {}, + sharedContext + ) + + normalizedInput = tags.map((tag) => ({ + id: tag.id, + ...data, + })) + } + + const tags = await this.productTagService_.update( + normalizedInput, sharedContext ) - return await this.baseRepository_.serialize(productTags) + const updatedTags = await this.baseRepository_.serialize< + ProductTypes.ProductTagDTO[] + >(tags) + + return isString(idOrSelector) ? updatedTags[0] : updatedTags } createTypes( diff --git a/packages/modules/product/src/types/index.ts b/packages/modules/product/src/types/index.ts index 39effeece4..04b25cfb11 100644 --- a/packages/modules/product/src/types/index.ts +++ b/packages/modules/product/src/types/index.ts @@ -57,6 +57,10 @@ export type UpdateTypeInput = ProductTypes.UpdateProductTypeDTO & { id: string } +export type UpdateTagInput = ProductTypes.UpdateProductTagDTO & { + id: string +} + export type UpdateProductVariantInput = ProductTypes.UpdateProductVariantDTO & { id: string product_id?: string | null