From 48411157b1cdec0a67f91e06de8ac547af89d7af Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 17 Oct 2022 11:03:38 +0200 Subject: [PATCH] feat(medusa): Support batch remove resources on discount condition (#2444) **what** - Add support to remove resources by batch on discount conditions - Add support on medusa-js and medusa-react **Tests** - Add integration tests to validate that the resources have been deleted and the length is the one expected - Add unit tests on medusa react FIXES CORE-609 --- .changeset/sixty-paws-cover.md | 7 + .../api/__tests__/admin/discount.js | 131 ++++++++++++++++++ .../src/resources/admin/discounts.ts | 14 ++ packages/medusa-react/mocks/handlers/admin.ts | 23 +++ .../src/hooks/admin/discounts/mutations.ts | 24 ++++ .../hooks/admin/discounts/mutations.test.ts | 43 ++++++ .../add-resources-to-condition-batch.ts | 7 +- .../delete-resources-from-condition-batch.ts | 129 +++++++++++++++++ .../src/api/routes/admin/discounts/index.ts | 15 ++ .../src/repositories/discount-condition.ts | 7 +- .../medusa/src/services/discount-condition.ts | 38 ++++- packages/medusa/src/types/discount.ts | 6 +- 12 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 .changeset/sixty-paws-cover.md create mode 100644 packages/medusa/src/api/routes/admin/discounts/delete-resources-from-condition-batch.ts diff --git a/.changeset/sixty-paws-cover.md b/.changeset/sixty-paws-cover.md new file mode 100644 index 0000000000..979fcf5a99 --- /dev/null +++ b/.changeset/sixty-paws-cover.md @@ -0,0 +1,7 @@ +--- +"@medusajs/medusa-js": patch +"medusa-react": patch +"@medusajs/medusa": patch +--- + +feat(medusa): Support batch remove resources on discount condition diff --git a/integration-tests/api/__tests__/admin/discount.js b/integration-tests/api/__tests__/admin/discount.js index 6b79a4629c..ae695865b7 100644 --- a/integration-tests/api/__tests__/admin/discount.js +++ b/integration-tests/api/__tests__/admin/discount.js @@ -2319,4 +2319,135 @@ describe("/admin/discounts", () => { ) }) }) + + describe("DELETE /admin/discounts/:id/conditions/:condition_id/batch", () => { + let prod1 + let prod2 + let prod3 + let prod4 + + beforeEach(async () => { + await adminSeeder(dbConnection) + + prod1 = await simpleProductFactory(dbConnection, { type: "pants" }) + prod2 = await simpleProductFactory(dbConnection, { type: "pants2" }) + prod3 = await simpleProductFactory(dbConnection, { type: "pants3" }) + prod4 = await simpleProductFactory(dbConnection, { type: "pants4" }) + + await simpleDiscountFactory(dbConnection, { + id: "test-discount", + code: "TEST", + rule: { + type: "percentage", + value: "10", + allocation: "total", + conditions: [ + { + id: "test-condition", + type: "products", + operator: "in", + products: [prod1.id, prod2.id, prod3.id, prod4.id], + }, + ], + }, + }) + + await simpleDiscountFactory(dbConnection, { + id: "test-discount-2", + code: "TEST2", + rule: { + type: "percentage", + value: "10", + allocation: "total", + conditions: [ + { + id: "test-condition-2", + type: "products", + operator: "in", + products: [], + }, + ], + }, + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update a condition with batch items to delete", async () => { + const api = useApi() + + const discount = await api.get( + "/admin/discounts/test-discount", + adminReqConfig + ) + + const cond = discount.data.discount.rule.conditions[0] + + const response = await api.delete( + `/admin/discounts/test-discount/conditions/${cond.id}/batch?expand=rule,rule.conditions,rule.conditions.products`, + { + ...adminReqConfig, + data: { + resources: [{ id: prod2.id }, { id: prod3.id }, { id: prod4.id }], + }, + } + ) + + const disc = response.data.discount + + expect(response.status).toEqual(200) + expect(disc.rule.conditions).toHaveLength(1) + expect(disc.rule.conditions[0].products).toHaveLength(1) + expect(disc).toEqual( + expect.objectContaining({ + id: "test-discount", + code: "TEST", + rule: expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + products: expect.arrayContaining([ + expect.objectContaining({ + id: prod1.id, + }), + ]), + }), + ]), + }), + }) + ) + }) + + it("should fail if condition does not belong to discount", async () => { + const api = useApi() + + const err = await api + .delete( + "/admin/discounts/test-discount/conditions/test-condition-2/batch", + adminReqConfig + ) + .catch((e) => e) + + expect(err.response.data.message).toBe( + "Condition with id test-condition-2 does not belong to Discount with id test-discount" + ) + }) + + it("should fail if discount does not exist", async () => { + const api = useApi() + + const err = await api + .delete( + "/admin/discounts/not-exist/conditions/test-condition/batch", + adminReqConfig + ) + .catch((e) => e) + + expect(err.response.data.message).toBe( + "Discount with id not-exist was not found" + ) + }) + }) }) diff --git a/packages/medusa-js/src/resources/admin/discounts.ts b/packages/medusa-js/src/resources/admin/discounts.ts index 6e37247bcb..d05eab1860 100644 --- a/packages/medusa-js/src/resources/admin/discounts.ts +++ b/packages/medusa-js/src/resources/admin/discounts.ts @@ -1,4 +1,5 @@ import { + AdminDeleteDiscountsDiscountConditionsConditionBatchReq, AdminDiscountConditionsRes, AdminDiscountsDeleteRes, AdminDiscountsListRes, @@ -230,6 +231,19 @@ class AdminDiscountsResource extends BaseResource { return this.client.request("POST", path, payload, {}, customHeaders) } + + /** + * @description Delete a batch of items from a discount condition + */ + deleteConditionResourceBatch( + discountId: string, + conditionId: string, + payload: AdminDeleteDiscountsDiscountConditionsConditionBatchReq, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/discounts/${discountId}/conditions/${conditionId}/batch` + return this.client.request("DELETE", path, payload, {}, customHeaders) + } } export default AdminDiscountsResource diff --git a/packages/medusa-react/mocks/handlers/admin.ts b/packages/medusa-react/mocks/handlers/admin.ts index e9c9a0c448..6325448d05 100644 --- a/packages/medusa-react/mocks/handlers/admin.ts +++ b/packages/medusa-react/mocks/handlers/admin.ts @@ -939,6 +939,29 @@ export const adminHandlers = [ } ), + rest.delete( + "/admin/discounts/:id/conditions/:conditionId/batch", + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + discount: { + ...fixtures.get("discount"), + rule: { + ...fixtures.get("discount").rule, + conditions: [ + { + ...fixtures.get("discount").rule.conditions[0], + products: [], + }, + ], + }, + }, + }) + ) + } + ), + rest.get("/admin/draft-orders/", (req, res, ctx) => { return res( ctx.status(200), diff --git a/packages/medusa-react/src/hooks/admin/discounts/mutations.ts b/packages/medusa-react/src/hooks/admin/discounts/mutations.ts index c79e13bd46..49d2ad8ae6 100644 --- a/packages/medusa-react/src/hooks/admin/discounts/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/discounts/mutations.ts @@ -1,4 +1,5 @@ import { + AdminDeleteDiscountsDiscountConditionsConditionBatchReq, AdminDiscountsDeleteRes, AdminDiscountsRes, AdminPostDiscountsDiscountConditions, @@ -39,6 +40,29 @@ export const useAdminAddDiscountConditionResourceBatch = ( ) } +export const useAdminDeleteDiscountConditionResourceBatch = ( + discountId: string, + conditionId: string, + options?: UseMutationOptions< + Response, + Error, + AdminDeleteDiscountsDiscountConditionsConditionBatchReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + (payload: AdminDeleteDiscountsDiscountConditionsConditionBatchReq) => + client.admin.discounts.deleteConditionResourceBatch( + discountId, + conditionId, + payload + ), + buildOptions(queryClient, [adminDiscountKeys.detail(discountId)], options) + ) +} + export const useAdminCreateDiscount = ( options?: UseMutationOptions< Response, diff --git a/packages/medusa-react/test/hooks/admin/discounts/mutations.test.ts b/packages/medusa-react/test/hooks/admin/discounts/mutations.test.ts index 9b18f99f4a..c7d630da11 100644 --- a/packages/medusa-react/test/hooks/admin/discounts/mutations.test.ts +++ b/packages/medusa-react/test/hooks/admin/discounts/mutations.test.ts @@ -6,6 +6,7 @@ import { useAdminCreateDiscount, useAdminCreateDynamicDiscountCode, useAdminDeleteDiscount, + useAdminDeleteDiscountConditionResourceBatch, useAdminDeleteDynamicDiscountCode, useAdminDiscountAddRegion, useAdminDiscountCreateCondition, @@ -16,6 +17,48 @@ import { } from "../../../../src/" import { createWrapper } from "../../../utils" +describe("useAdminDeleteDiscountConditionResourceBatch hook", () => { + test("delete items from a discount condition and return the discount", async () => { + const resources = [ + { + id: fixtures.get("product").id, + }, + ] + const discountId = fixtures.get("discount").id + const conditionId = fixtures.get("discount").rule.conditions[0].id + + const { result, waitFor } = renderHook( + () => + useAdminDeleteDiscountConditionResourceBatch(discountId, conditionId), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate({ + resources, + }) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.discount).toEqual( + expect.objectContaining({ + ...fixtures.get("discount"), + rule: { + ...fixtures.get("discount").rule, + conditions: [ + { + ...fixtures.get("discount").rule.conditions[0], + products: [], + }, + ], + }, + }) + ) + }) +}) + describe("useAdminAddDiscountConditionResourceBatch hook", () => { test("add items to a discount condition and return the discount", async () => { const resources = [ diff --git a/packages/medusa/src/api/routes/admin/discounts/add-resources-to-condition-batch.ts b/packages/medusa/src/api/routes/admin/discounts/add-resources-to-condition-batch.ts index 9c13d5b179..143471cb8c 100644 --- a/packages/medusa/src/api/routes/admin/discounts/add-resources-to-condition-batch.ts +++ b/packages/medusa/src/api/routes/admin/discounts/add-resources-to-condition-batch.ts @@ -3,8 +3,8 @@ import { Request, Response } from "express" import { EntityManager } from "typeorm" import { DiscountService } from "../../../../services" import { + DiscountConditionInput, DiscountConditionMapTypeToProperty, - UpsertDiscountConditionInput, } from "../../../../types/discount" import { IsArray } from "class-validator" import { FindParams } from "../../../../types/common" @@ -18,7 +18,7 @@ import { FindParams } from "../../../../types/common" * parameters: * - (path) discount_id=* {string} The ID of the Product. * - (path) condition_id=* {string} The ID of the condition on which to add the item. - * - (query) expand {string} (Comma separated) Which fields should be expanded in each discount of the result. + * - (query) expand {string} (Comma separated) Which relations should be expanded in each discount of the result. * - (query) fields {string} (Comma separated) Which fields should be included in each discount of the result. * requestBody: * content: @@ -42,7 +42,6 @@ import { FindParams } from "../../../../types/common" * label: JS Client * source: | * import Medusa from "@medusajs/medusa-js" - * import { DiscountConditionOperator } from "@medusajs/medusa" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) * // must be previously logged in or use api token * medusa.admin.discounts.addConditionResourceBatch(discount_id, condition_id, { @@ -101,7 +100,7 @@ export default async (req: Request, res: Response) => { select: ["id", "type", "discount_rule_id"], }) - const updateObj: UpsertDiscountConditionInput = { + const updateObj: DiscountConditionInput = { id: condition_id, rule_id: condition.discount_rule_id, [DiscountConditionMapTypeToProperty[condition.type]]: diff --git a/packages/medusa/src/api/routes/admin/discounts/delete-resources-from-condition-batch.ts b/packages/medusa/src/api/routes/admin/discounts/delete-resources-from-condition-batch.ts new file mode 100644 index 0000000000..af1022a14a --- /dev/null +++ b/packages/medusa/src/api/routes/admin/discounts/delete-resources-from-condition-batch.ts @@ -0,0 +1,129 @@ +import { Request, Response } from "express" +import { EntityManager } from "typeorm" +import { DiscountConditionService, DiscountService } from "../../../../services" +import { + DiscountConditionInput, + DiscountConditionMapTypeToProperty, +} from "../../../../types/discount" +import { IsArray } from "class-validator" +import { FindParams } from "../../../../types/common" + +/** + * @oas [delete] /discounts/{discount_id}/conditions/{condition_id}/batch + * operationId: "DeleteDiscountsDiscountConditionsConditionBatch" + * summary: "Delete a batch of resources from a discount condition" + * description: "Delete a batch of resources from a discount condition." + * x-authenticated: true + * parameters: + * - (path) discount_id=* {string} The ID of the Product. + * - (path) condition_id=* {string} The ID of the condition on which to add the item. + * - (query) expand {string} (Comma separated) Which relations should be expanded in each discount of the result. + * - (query) fields {string} (Comma separated) Which fields should be included in each discount of the result. + * requestBody: + * content: + * application/json: + * schema: + * required: + * - resources + * properties: + * resources: + * description: The resources to be deleted from the discount condition + * type: array + * items: + * required: + * - id + * properties: + * id: + * description: The id of the item + * type: string + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.discounts.deleteConditionResourceBatch(discount_id, condition_id, { + * resources: [{ id: item_id }] + * }) + * .then(({ discount }) => { + * console.log(discount.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request DELETE 'https://medusa-url.com/admin/discounts/{id}/conditions/{condition_id}/batch' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "resources": [{ "id": "item_id" }] + * }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Discount Condition + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * discount: + * $ref: "#/components/schemas/discount" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { discount_id, condition_id } = req.params + + const conditionService: DiscountConditionService = req.scope.resolve( + "discountConditionService" + ) + const manager: EntityManager = req.scope.resolve("manager") + + const condition = await conditionService.retrieve(condition_id, { + select: ["id", "type", "discount_rule_id"], + }) + + const validatedBody = + req.validatedBody as AdminDeleteDiscountsDiscountConditionsConditionBatchReq + const data = { + id: condition_id, + rule_id: condition.discount_rule_id, + [DiscountConditionMapTypeToProperty[condition.type]]: + validatedBody.resources, + } as Omit & { id: string } + + await manager.transaction(async (transactionManager) => { + await conditionService + .withTransaction(transactionManager) + .removeResources(data) + }) + + const discountService: DiscountService = req.scope.resolve("discountService") + const discount = await discountService.retrieve( + discount_id, + req.retrieveConfig + ) + + res.status(200).json({ discount }) +} + +export class AdminDeleteDiscountsDiscountConditionsConditionBatchParams extends FindParams {} + +export class AdminDeleteDiscountsDiscountConditionsConditionBatchReq { + @IsArray() + resources: { id: string }[] +} diff --git a/packages/medusa/src/api/routes/admin/discounts/index.ts b/packages/medusa/src/api/routes/admin/discounts/index.ts index 49e7779f7f..b47dee753d 100644 --- a/packages/medusa/src/api/routes/admin/discounts/index.ts +++ b/packages/medusa/src/api/routes/admin/discounts/index.ts @@ -34,6 +34,10 @@ import { AdminGetDiscountsDiscountConditionsConditionParams } from "./get-condit import { AdminDeleteDiscountsDiscountConditionsConditionParams } from "./delete-condition" import { AdminGetDiscountsDiscountCodeParams } from "./get-discount-by-code" import { AdminGetDiscountParams } from "./get-discount" +import { + AdminDeleteDiscountsDiscountConditionsConditionBatchParams, + AdminDeleteDiscountsDiscountConditionsConditionBatchReq, +} from "./delete-resources-from-condition-batch" const route = Router() @@ -172,6 +176,16 @@ export default (app) => { transformBody(AdminPostDiscountsDiscountConditionsConditionBatchReq), middlewares.wrap(require("./add-resources-to-condition-batch").default) ) + conditionRouter.delete( + "/batch", + transformQuery(AdminDeleteDiscountsDiscountConditionsConditionBatchParams, { + defaultFields: defaultAdminDiscountsFields, + defaultRelations: defaultAdminDiscountsRelations, + isList: false, + }), + transformBody(AdminDeleteDiscountsDiscountConditionsConditionBatchReq), + middlewares.wrap(require("./delete-resources-from-condition-batch").default) + ) return app } @@ -235,3 +249,4 @@ export * from "./remove-region" export * from "./update-condition" export * from "./update-discount" export * from "./add-resources-to-condition-batch" +export * from "./delete-resources-from-condition-batch" diff --git a/packages/medusa/src/repositories/discount-condition.ts b/packages/medusa/src/repositories/discount-condition.ts index fe6a730678..8e59b2f1c4 100644 --- a/packages/medusa/src/repositories/discount-condition.ts +++ b/packages/medusa/src/repositories/discount-condition.ts @@ -133,7 +133,7 @@ export class DiscountConditionRepository extends Repository { async removeConditionResources( id: string, type: DiscountConditionType, - resourceIds: string[] + resourceIds: (string | { id: string })[] ): Promise { const { conditionTable, joinTableForeignKey } = this.getJoinTableResourceIdentifiers(type) @@ -142,10 +142,13 @@ export class DiscountConditionRepository extends Repository { return Promise.resolve() } + const idsToDelete = resourceIds.map((rId): string => { + return isString(rId) ? rId : rId.id + }) return await this.createQueryBuilder() .delete() .from(conditionTable) - .where({ condition_id: id, [joinTableForeignKey]: In(resourceIds) }) + .where({ condition_id: id, [joinTableForeignKey]: In(idsToDelete) }) .execute() } diff --git a/packages/medusa/src/services/discount-condition.ts b/packages/medusa/src/services/discount-condition.ts index 17d9e9b30d..3467e3835e 100644 --- a/packages/medusa/src/services/discount-condition.ts +++ b/packages/medusa/src/services/discount-condition.ts @@ -12,7 +12,7 @@ import { } from "../models" import { DiscountConditionRepository } from "../repositories/discount-condition" import { FindConfig } from "../types/common" -import { UpsertDiscountConditionInput } from "../types/discount" +import { DiscountConditionInput } from "../types/discount" import { TransactionBaseService } from "../interfaces" import { buildQuery, PostgresError } from "../utils" @@ -69,7 +69,7 @@ class DiscountConditionService extends TransactionBaseService { return condition } - protected static resolveConditionType_(data: UpsertDiscountConditionInput): + protected static resolveConditionType_(data: DiscountConditionInput): | { type: DiscountConditionType resource_ids: (string | { id: string })[] @@ -107,7 +107,7 @@ class DiscountConditionService extends TransactionBaseService { } async upsertCondition( - data: UpsertDiscountConditionInput, + data: DiscountConditionInput, overrideExisting: boolean = true ): Promise< ( @@ -178,6 +178,38 @@ class DiscountConditionService extends TransactionBaseService { ) } + async removeResources( + data: Omit & { id: string } + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const resolvedConditionType = + DiscountConditionService.resolveConditionType_(data) + + if (!resolvedConditionType) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Missing one of products, collections, tags, types or customer groups in data` + ) + } + + const discountConditionRepo: DiscountConditionRepository = + manager.getCustomRepository(this.discountConditionRepository_) + + const resolvedCondition = await this.retrieve(data.id) + + if (data.operator && data.operator !== resolvedCondition.operator) { + resolvedCondition.operator = data.operator + await discountConditionRepo.save(resolvedCondition) + } + + await discountConditionRepo.removeConditionResources( + data.id, + resolvedConditionType.type, + resolvedConditionType.resource_ids + ) + }) + } + async delete(discountConditionId: string): Promise { return await this.atomicPhase_(async (manager: EntityManager) => { const conditionRepo = manager.getCustomRepository( diff --git a/packages/medusa/src/types/discount.ts b/packages/medusa/src/types/discount.ts index d2d2648014..196fbec68f 100644 --- a/packages/medusa/src/types/discount.ts +++ b/packages/medusa/src/types/discount.ts @@ -117,7 +117,7 @@ export const DiscountConditionMapTypeToProperty = { [DiscountConditionType.CUSTOMER_GROUPS]: "customer_groups", } -export type UpsertDiscountConditionInput = { +export type DiscountConditionInput = { rule_id?: string id?: string operator?: DiscountConditionOperator @@ -133,7 +133,7 @@ export type CreateDiscountRuleInput = { type: DiscountRuleType value: number allocation: AllocationType - conditions?: UpsertDiscountConditionInput[] + conditions?: DiscountConditionInput[] } export type CreateDiscountInput = { @@ -154,7 +154,7 @@ export type UpdateDiscountRuleInput = { description?: string value?: number allocation?: AllocationType - conditions?: UpsertDiscountConditionInput[] + conditions?: DiscountConditionInput[] } export type UpdateDiscountInput = {