From 4b4463f0e2b97f31dca48d859b7ede0295cabedf Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Mon, 28 Feb 2022 10:03:26 +0100 Subject: [PATCH] Feat: Bulk add customers to customer group (#1095) * fix babel transform-runtime regenerator required for migrations * add customer group model * add migration for customer group * add customer group model export * add customer group repository * add customer group service * add CustomerGroupRepository to "withTransaction" in CustomerGroupService * remove unnecessary argument to runtime plugin * service export ordering * add create customer group endpoint * add customergroup to route index in admin * add customer group service * add customer groups test * cleanup * add customers batch initial * batch creation of customer groups * integration testing batch creation * integration tests * chaining existing customers creation in repo * remove commented test * update unit tests to reflect change in idempotent behavior * ensure that exceptions are expected * update idempotency behavior * update formatting * update format * Update packages/medusa/src/repositories/customer-group.ts Co-authored-by: Sebastian Rindom * pr feedback * add In import * add seperate model dto * add integration test * error handling in repository * remove unused import * jsdoc * Update packages/medusa/src/api/routes/admin/customer-groups/add-customers-batch.ts Co-authored-by: Sebastian Rindom * Update packages/medusa/src/api/routes/admin/customer-groups/add-customers-batch.ts Co-authored-by: Sebastian Rindom * pr review comments * rename variable * fix: adds atomic phase clean up callback * fix: call error handler in new transaction block too * restore * error handling * fix: error handler in no isolation case * add integration test for missing group on update * final adjustments to test * fix pr feedback * cleanup core for pr * remove console.log * remove customergroupservice test from customers * Apply suggestions from code review Co-authored-by: Sebastian Rindom * add end bracket to customer tests * remove comments * change model decorator * fix integration test merge * onDelete cascade instead of cascade:true * remove verbose flag * fix: dedupe type * add save to customer groups * customer model delete cascade * add await to asyncronous save operations Co-authored-by: Sebastian Rindom --- .../api/__tests__/admin/customer-groups.js | 182 +++++++++++++++- .../api/__tests__/admin/customer.js | 52 ++--- .../api/__tests__/admin/discount.js | 4 +- .../api/__tests__/admin/product.js | 4 +- .../api/__tests__/taxes/admin-tax-rates.js | 2 +- .../api/helpers/customer-seeder.js | 7 + .../src/api/middlewares/error-handler.js | 6 + .../collections/__tests__/add-products.js | 11 +- .../customer-groups/add-customers-batch.ts | 51 +++++ .../api/routes/admin/customer-groups/index.ts | 4 + packages/medusa/src/models/customer.ts | 4 +- .../medusa/src/repositories/customer-group.ts | 21 ++ .../services/__mocks__/product-collection.js | 8 +- .../medusa/src/services/__tests__/customer.js | 201 +++++++++--------- .../medusa/src/services/customer-group.ts | 66 +++++- packages/medusa/src/services/customer.js | 108 +++++----- packages/medusa/src/services/discount.js | 36 ++-- .../medusa/src/services/product-collection.js | 23 +- packages/medusa/src/services/product.js | 57 ++--- packages/medusa/src/types/customer-groups.ts | 1 + .../medusa/src/utils/exception-formatter.ts | 41 ++++ 21 files changed, 638 insertions(+), 251 deletions(-) create mode 100644 packages/medusa/src/api/routes/admin/customer-groups/add-customers-batch.ts create mode 100644 packages/medusa/src/utils/exception-formatter.ts diff --git a/integration-tests/api/__tests__/admin/customer-groups.js b/integration-tests/api/__tests__/admin/customer-groups.js index c42eae7590..e628f962e8 100644 --- a/integration-tests/api/__tests__/admin/customer-groups.js +++ b/integration-tests/api/__tests__/admin/customer-groups.js @@ -130,7 +130,7 @@ describe("/admin/customer-groups", () => { .catch((error) => { expect(error.response.data.type).toEqual("not_found") expect(error.response.data.message).toEqual( - `CustomerGroup with ${id} was not found` + `CustomerGroup with id ${id} was not found` ) }) }) @@ -193,6 +193,182 @@ describe("/admin/customer-groups", () => { }) }) + describe("POST /admin/customer-groups/{id}/customers/batch", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await customerSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("adds multiple customers to a group", async () => { + const api = useApi() + + const payload = { + customer_ids: [{ id: "test-customer-1" }, { id: "test-customer-2" }], + } + + const batchAddResponse = await api.post( + "/admin/customer-groups/customer-group-1/customers/batch", + payload, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + + expect(batchAddResponse.status).toEqual(200) + expect(batchAddResponse.data.customer_group).toEqual( + expect.objectContaining({ + name: "vip-customers", + }) + ) + + const getCustomerResponse = await api.get( + "/admin/customers?expand=groups", + { + headers: { Authorization: "Bearer test_token" }, + } + ) + + expect(getCustomerResponse.data.customers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "test-customer-1", + groups: [ + expect.objectContaining({ + name: "vip-customers", + id: "customer-group-1", + }), + ], + }), + expect.objectContaining({ + id: "test-customer-2", + groups: [ + expect.objectContaining({ + name: "vip-customers", + id: "customer-group-1", + }), + ], + }), + ]) + ) + }) + + it("presents a descriptive error when presented with a non-existing group", async () => { + expect.assertions(2) + + const api = useApi() + + const payload = { + customer_ids: [{ id: "test-customer-1" }, { id: "test-customer-2" }], + } + + await api + .post( + "/admin/customer-groups/non-existing-customer-group-1/customers/batch", + payload, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + expect(err.response.data.type).toEqual("not_found") + expect(err.response.data.message).toEqual( + "CustomerGroup with id non-existing-customer-group-1 was not found" + ) + }) + }) + + it("adds customers to a group idempotently", async () => { + expect.assertions(3) + + const api = useApi() + + // add customer-1 to the customer group + const payload_1 = { + customer_ids: [{ id: "test-customer-1" }], + } + + await api + .post( + "/admin/customer-groups/customer-group-1/customers/batch", + payload_1, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => console.log(err)) + + // re-adding customer-1 to the customer group along with new addintion: + // customer-2 and some non-existing customers should cause the request to fail + const payload_2 = { + customer_ids: [ + { id: "test-customer-1" }, + { id: "test-customer-27" }, + { id: "test-customer-28" }, + { id: "test-customer-2" }, + ], + } + + await api + .post( + "/admin/customer-groups/customer-group-1/customers/batch", + payload_2, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + expect(err.response.data.type).toEqual("not_found") + expect(err.response.data.message).toEqual( + 'The following customer ids do not exist: "test-customer-27, test-customer-28"' + ) + }) + + // check that customer-1 is only added once and that customer-2 is added correctly + const getCustomerResponse = await api.get( + "/admin/customers?expand=groups", + { + headers: { Authorization: "Bearer test_token" }, + } + ) + + expect(getCustomerResponse.data.customers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "test-customer-1", + groups: [ + expect.objectContaining({ + name: "vip-customers", + id: "customer-group-1", + }), + ], + }), + expect.objectContaining({ + id: "test-customer-2", + groups: [], + }), + ]) + ) + }) + }) + describe("POST /admin/customer-groups/:id", () => { beforeEach(async () => { try { @@ -336,8 +512,6 @@ describe("/admin/customer-groups", () => { }) it("throws error when a customer group doesn't exist", async () => { - expect.assertions(3) - const api = useApi() const id = "test-group-000" @@ -352,7 +526,7 @@ describe("/admin/customer-groups", () => { expect(err.response.status).toEqual(404) expect(err.response.data.type).toEqual("not_found") expect(err.response.data.message).toEqual( - `CustomerGroup with ${id} was not found` + `CustomerGroup with id ${id} was not found` ) }) }) diff --git a/integration-tests/api/__tests__/admin/customer.js b/integration-tests/api/__tests__/admin/customer.js index df4fdbc6f7..81a2a82358 100644 --- a/integration-tests/api/__tests__/admin/customer.js +++ b/integration-tests/api/__tests__/admin/customer.js @@ -229,6 +229,32 @@ describe("/admin/customers", () => { ) }) + it("fails when adding a customer group which doesn't exist", async () => { + expect.assertions(3) + // Try adding a non existing group + const api = useApi() + + await api + .post( + "/admin/customers/test-customer-3?expand=groups", + { + groups: [{ id: "fake-group-0" }], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((error) => { + expect(error.response.status).toEqual(404) + expect(error.response.data.type).toEqual("not_found") + expect(error.response.data.message).toEqual( + "Customer_group with customer_group_id fake-group-0 does not exist." + ) + }) + }) + it("Correctly updates customer groups", async () => { const api = useApi() let response = await api @@ -254,32 +280,6 @@ describe("/admin/customers", () => { ]) ) - // Try adding a non existing group - - response = await api - .post( - "/admin/customers/test-customer-3?expand=groups", - { - groups: [{ id: "test-group-4" }, { id: "fake-group-0" }], - }, - { - headers: { - Authorization: "Bearer test_token", - }, - } - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.customer.groups.length).toEqual(1) - expect(response.data.customer.groups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "test-group-4", name: "test-group-4" }), - ]) - ) - // Delete all groups response = await api diff --git a/integration-tests/api/__tests__/admin/discount.js b/integration-tests/api/__tests__/admin/discount.js index e58772f865..fcac4a9f4c 100644 --- a/integration-tests/api/__tests__/admin/discount.js +++ b/integration-tests/api/__tests__/admin/discount.js @@ -882,8 +882,8 @@ describe("/admin/discounts", () => { } ) } catch (error) { - expect(error.response.data.message).toMatch( - /duplicate key value violates unique constraint/i + expect(error.response.data.message).toEqual( + "Discount with code TESTING already exists." ) } }) diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 34e4baef0e..8c9b4576e0 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -1627,7 +1627,7 @@ describe("/admin/products", () => { }) } catch (error) { expect(error.response.data.message).toMatch( - /duplicate key value violates unique constraint/i + "Product with handle test-product already exists." ) } }) @@ -1699,7 +1699,7 @@ describe("/admin/products", () => { }) } catch (error) { expect(error.response.data.message).toMatch( - /duplicate key value violates unique constraint/i + "Product_collection with handle test-collection already exists." ) } }) diff --git a/integration-tests/api/__tests__/taxes/admin-tax-rates.js b/integration-tests/api/__tests__/taxes/admin-tax-rates.js index 0303c7ba56..637062926b 100644 --- a/integration-tests/api/__tests__/taxes/admin-tax-rates.js +++ b/integration-tests/api/__tests__/taxes/admin-tax-rates.js @@ -28,7 +28,7 @@ describe("/admin/tax-rates", () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) try { dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd, verbose: true }) + medusaProcess = await setupServer({ cwd }) } catch (error) { console.log(error) } diff --git a/integration-tests/api/helpers/customer-seeder.js b/integration-tests/api/helpers/customer-seeder.js index 34e6d56811..e70bcf9cf2 100644 --- a/integration-tests/api/helpers/customer-seeder.js +++ b/integration-tests/api/helpers/customer-seeder.js @@ -40,6 +40,7 @@ module.exports = async (connection, data = {}) => { id: "test-customer-delete-cg", email: "test-deletetion-cg@email.com", }) + await manager.save(deletionCustomer) await manager.insert(CustomerGroup, { id: "customer-group-1", @@ -66,26 +67,31 @@ module.exports = async (connection, data = {}) => { id: "test-customer-5", email: "test5@email.com", }) + await manager.save(customer5) const customer6 = manager.create(Customer, { id: "test-customer-6", email: "test6@email.com", }) + await manager.save(customer6) const customer7 = manager.create(Customer, { id: "test-customer-7", email: "test7@email.com", }) + await manager.save(customer7) const c_group_5 = manager.create(CustomerGroup, { id: "test-group-5", name: "test-group-5", }) + await manager.save(c_group_5) const c_group_6 = manager.create(CustomerGroup, { id: "test-group-6", name: "test-group-6", }) + await manager.save(c_group_6) customer5.groups = [c_group_5] await manager.save(customer5) @@ -100,6 +106,7 @@ module.exports = async (connection, data = {}) => { id: "test-group-delete", name: "test-group-delete", }) + await manager.save(c_group_delete) deletionCustomer.groups = [c_group_delete] await manager.save(deletionCustomer) diff --git a/packages/medusa/src/api/middlewares/error-handler.js b/packages/medusa/src/api/middlewares/error-handler.js index edb315b06e..03659a8659 100644 --- a/packages/medusa/src/api/middlewares/error-handler.js +++ b/packages/medusa/src/api/middlewares/error-handler.js @@ -46,7 +46,13 @@ export default () => { statusCode = 500 errObj.code = API_ERROR break + case MedusaError.Types.UNEXPECTED_STATE: + case MedusaError.Types.INVALID_ARGUMENT: + break default: + errObj.code = "unknown_error" + errObj.message = "An unknown error occurred." + errObj.type = "unknown_error" break } diff --git a/packages/medusa/src/api/routes/admin/collections/__tests__/add-products.js b/packages/medusa/src/api/routes/admin/collections/__tests__/add-products.js index b576af071b..7e828518f1 100644 --- a/packages/medusa/src/api/routes/admin/collections/__tests__/add-products.js +++ b/packages/medusa/src/api/routes/admin/collections/__tests__/add-products.js @@ -33,9 +33,10 @@ describe("POST /admin/collections/:id/products/batch", () => { it("product collection service update", () => { expect(ProductCollectionServiceMock.addProducts).toHaveBeenCalledTimes(1) - expect( - ProductCollectionServiceMock.addProducts - ).toHaveBeenCalledWith(IdMap.getId("col"), ["prod_1", "prod_2"]) + expect(ProductCollectionServiceMock.addProducts).toHaveBeenCalledWith( + IdMap.getId("col"), + ["prod_1", "prod_2"] + ) }) }) @@ -60,7 +61,9 @@ describe("POST /admin/collections/:id/products/batch", () => { }) it("throws error", () => { - expect(subject.body.message).toBe("Product collection not found") + expect(subject.body.message).toBe( + "Product collection with id: null was not found" + ) }) }) diff --git a/packages/medusa/src/api/routes/admin/customer-groups/add-customers-batch.ts b/packages/medusa/src/api/routes/admin/customer-groups/add-customers-batch.ts new file mode 100644 index 0000000000..8778f7ac72 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/customer-groups/add-customers-batch.ts @@ -0,0 +1,51 @@ +import { Type } from "class-transformer" +import { ValidateNested } from "class-validator" +import { CustomerGroupService } from "../../../../services" +import { CustomerGroupsBatchCustomer } from "../../../../types/customer-groups" +import { validator } from "../../../../utils/validator" + +/** + * @oas [post] /customer-groups/{id}/customers/batch + * operationId: "PostCustomerGroupsGroupCustomersBatch" + * summary: "Add a list of customers to a customer group " + * description: "Adds a list of customers, represented by id's, to a customer group." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the customer group. + * - (body) customers=* {{id: string }[]} ids of the customers to add + * tags: + * - CustomerGroup + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * customerGroup: + * $ref: "#/components/schemas/customergroup" + */ + +export default async (req, res) => { + const { id } = req.params + const validated = await validator( + AdminPostCustomerGroupsGroupCustomersBatchReq, + req.body + ) + + const customerGroupService: CustomerGroupService = req.scope.resolve( + "customerGroupService" + ) + + const customer_group = await customerGroupService.addCustomers( + id, + validated.customer_ids.map(({ id }) => id) + ) + res.status(200).json({ customer_group }) +} + +export class AdminPostCustomerGroupsGroupCustomersBatchReq { + @ValidateNested({ each: true }) + @Type(() => CustomerGroupsBatchCustomer) + customer_ids: CustomerGroupsBatchCustomer[] +} diff --git a/packages/medusa/src/api/routes/admin/customer-groups/index.ts b/packages/medusa/src/api/routes/admin/customer-groups/index.ts index bfcb68e536..eff71ef643 100644 --- a/packages/medusa/src/api/routes/admin/customer-groups/index.ts +++ b/packages/medusa/src/api/routes/admin/customer-groups/index.ts @@ -10,6 +10,10 @@ export default (app) => { route.get("/:id", middlewares.wrap(require("./get-customer-group").default)) route.post("/", middlewares.wrap(require("./create-customer-group").default)) + route.post( + "/:id/customers/batch", + middlewares.wrap(require("./add-customers-batch").default) + ) route.delete( "/:id/customers/batch", middlewares.wrap(require("./delete-customers-batch").default) diff --git a/packages/medusa/src/models/customer.ts b/packages/medusa/src/models/customer.ts index 3c66552a9f..586e526370 100644 --- a/packages/medusa/src/models/customer.ts +++ b/packages/medusa/src/models/customer.ts @@ -69,7 +69,9 @@ export class Customer { referencedColumnName: "id", }, }) - @ManyToMany(() => CustomerGroup, (cg) => cg.customers, { cascade: true }) + @ManyToMany(() => CustomerGroup, (cg) => cg.customers, { + onDelete: "CASCADE", + }) groups: CustomerGroup[] @CreateDateColumn({ type: resolveDbType("timestamptz") }) diff --git a/packages/medusa/src/repositories/customer-group.ts b/packages/medusa/src/repositories/customer-group.ts index 9a9f09e447..3a7f7a356f 100644 --- a/packages/medusa/src/repositories/customer-group.ts +++ b/packages/medusa/src/repositories/customer-group.ts @@ -3,6 +3,27 @@ import { CustomerGroup } from "../models/customer-group" @EntityRepository(CustomerGroup) export class CustomerGroupRepository extends Repository { + async addCustomers( + groupId: string, + customerIds: string[] + ): Promise { + const customerGroup = await this.findOne(groupId) + + await this.createQueryBuilder() + .insert() + .into("customer_group_customers") + .values( + customerIds.map((id) => ({ + customer_id: id, + customer_group_id: groupId, + })) + ) + .orIgnore() + .execute() + + return customerGroup as CustomerGroup + } + async removeCustomers( groupId: string, customerIds: string[] diff --git a/packages/medusa/src/services/__mocks__/product-collection.js b/packages/medusa/src/services/__mocks__/product-collection.js index 1eff830d7d..94e0c43fab 100644 --- a/packages/medusa/src/services/__mocks__/product-collection.js +++ b/packages/medusa/src/services/__mocks__/product-collection.js @@ -1,7 +1,8 @@ +import { MedusaError } from "medusa-core-utils" import { IdMap } from "medusa-test-utils" export const ProductCollectionServiceMock = { - withTransaction: function() { + withTransaction: function () { return this }, create: jest.fn().mockImplementation((data) => { @@ -23,7 +24,10 @@ export const ProductCollectionServiceMock = { products: product_ids.map((i) => ({ id: i })), }) } - throw new Error("Product collection not found") + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product collection with id: ${id} was not found` + ) }), removeProducts: jest.fn().mockReturnValue(Promise.resolve()), list: jest.fn().mockImplementation((data) => { diff --git a/packages/medusa/src/services/__tests__/customer.js b/packages/medusa/src/services/__tests__/customer.js index 319e954c17..2b547f32d2 100644 --- a/packages/medusa/src/services/__tests__/customer.js +++ b/packages/medusa/src/services/__tests__/customer.js @@ -3,13 +3,13 @@ import CustomerService from "../customer" const eventBusService = { emit: jest.fn(), - withTransaction: function() { + withTransaction: function () { return this }, } const customerGroupService = { - withTransaction: function() { + withTransaction: function () { return this }, list: jest.fn().mockImplementation(() => Promise.resolve()), @@ -282,42 +282,43 @@ describe("CustomerService", () => { jest.clearAllMocks() }) - it("calls `customerGroupService.list` if `groups` prop is received as a param", async () => { - await customerService.update(IdMap.getId("ironman"), { - groups: [{ id: "group-id" }], + describe("updateAddress", () => { + const addressRepository = MockRepository({ + findOne: (query) => { + return Promise.resolve({ + id: IdMap.getId("hollywood-boulevard"), + address_1: "Hollywood Boulevard 2", + }) + }, }) - expect(customerGroupService.list).toBeCalledTimes(1) - expect(customerGroupService.list).toBeCalledWith({ id: ["group-id"] }) + const customerService = new CustomerService({ + manager: MockManager, + addressRepository, + }) - expect(customerRepository.save).toBeCalledTimes(1) - }) - }) + beforeEach(async () => { + jest.clearAllMocks() + }) - describe("updateAddress", () => { - const addressRepository = MockRepository({ - findOne: (query) => { - return Promise.resolve({ + it("successfully updates address", async () => { + await customerService.updateAddress( + IdMap.getId("ironman"), + IdMap.getId("hollywood-boulevard"), + { + first_name: "Tony", + last_name: "Stark", + address_1: "Hollywood Boulevard 1", + city: "Los Angeles", + country_code: "us", + postal_code: "90046", + phone: "+1 (222) 333 4444", + } + ) + + expect(addressRepository.save).toBeCalledTimes(1) + expect(addressRepository.save).toBeCalledWith({ id: IdMap.getId("hollywood-boulevard"), - address_1: "Hollywood Boulevard 2", - }) - }, - }) - - const customerService = new CustomerService({ - manager: MockManager, - addressRepository, - }) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("successfully updates address", async () => { - await customerService.updateAddress( - IdMap.getId("ironman"), - IdMap.getId("hollywood-boulevard"), - { first_name: "Tony", last_name: "Stark", address_1: "Hollywood Boulevard 1", @@ -325,94 +326,82 @@ describe("CustomerService", () => { country_code: "us", postal_code: "90046", phone: "+1 (222) 333 4444", - } - ) + }) + }) - expect(addressRepository.save).toBeCalledTimes(1) - expect(addressRepository.save).toBeCalledWith({ - id: IdMap.getId("hollywood-boulevard"), - first_name: "Tony", - last_name: "Stark", - address_1: "Hollywood Boulevard 1", - city: "Los Angeles", - country_code: "us", - postal_code: "90046", - phone: "+1 (222) 333 4444", + it("throws on invalid address", async () => { + await expect( + customerService.updateAddress( + IdMap.getId("ironman"), + IdMap.getId("hollywood-boulevard"), + { + first_name: "Tony", + last_name: "Stark", + country_code: "us", + unknown: "key", + address_1: "Hollywood", + } + ) + ).rejects.toThrow("The address is not valid") }) }) - it("throws on invalid address", async () => { - await expect( - customerService.updateAddress( - IdMap.getId("ironman"), - IdMap.getId("hollywood-boulevard"), - { - first_name: "Tony", - last_name: "Stark", - country_code: "us", - unknown: "key", - address_1: "Hollywood", - } - ) - ).rejects.toThrow("The address is not valid") - }) - }) + describe("removeAddress", () => { + const addressRepository = MockRepository({ + findOne: (query) => { + return Promise.resolve({ + id: IdMap.getId("hollywood-boulevard"), + address_1: "Hollywood Boulevard 2", + }) + }, + }) - describe("removeAddress", () => { - const addressRepository = MockRepository({ - findOne: (query) => { - return Promise.resolve({ + const customerService = new CustomerService({ + manager: MockManager, + addressRepository, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("successfully deletes address", async () => { + await customerService.removeAddress( + IdMap.getId("ironman"), + IdMap.getId("hollywood-boulevard") + ) + + expect(addressRepository.softRemove).toBeCalledTimes(1) + expect(addressRepository.softRemove).toBeCalledWith({ id: IdMap.getId("hollywood-boulevard"), address_1: "Hollywood Boulevard 2", }) - }, - }) - - const customerService = new CustomerService({ - manager: MockManager, - addressRepository, - }) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("successfully deletes address", async () => { - await customerService.removeAddress( - IdMap.getId("ironman"), - IdMap.getId("hollywood-boulevard") - ) - - expect(addressRepository.softRemove).toBeCalledTimes(1) - expect(addressRepository.softRemove).toBeCalledWith({ - id: IdMap.getId("hollywood-boulevard"), - address_1: "Hollywood Boulevard 2", }) }) - }) - describe("delete", () => { - const customerRepository = MockRepository({ - findOne: (query) => { - return Promise.resolve({ id: IdMap.getId("ironman") }) - }, - }) + describe("delete", () => { + const customerRepository = MockRepository({ + findOne: (query) => { + return Promise.resolve({ id: IdMap.getId("ironman") }) + }, + }) - const customerService = new CustomerService({ - manager: MockManager, - customerRepository, - }) + const customerService = new CustomerService({ + manager: MockManager, + customerRepository, + }) - beforeEach(async () => { - jest.clearAllMocks() - }) + beforeEach(async () => { + jest.clearAllMocks() + }) - it("successfully deletes customer", async () => { - await customerService.delete(IdMap.getId("ironman")) + it("successfully deletes customer", async () => { + await customerService.delete(IdMap.getId("ironman")) - expect(customerRepository.softRemove).toBeCalledTimes(1) - expect(customerRepository.softRemove).toBeCalledWith({ - id: IdMap.getId("ironman"), + expect(customerRepository.softRemove).toBeCalledTimes(1) + expect(customerRepository.softRemove).toBeCalledWith({ + id: IdMap.getId("ironman"), + }) }) }) }) diff --git a/packages/medusa/src/services/customer-group.ts b/packages/medusa/src/services/customer-group.ts index d6bba1c194..656b40e819 100644 --- a/packages/medusa/src/services/customer-group.ts +++ b/packages/medusa/src/services/customer-group.ts @@ -1,9 +1,11 @@ import { MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" import { DeepPartial, EntityManager } from "typeorm" +import { CustomerService } from "." import { CustomerGroup } from ".." import { CustomerGroupRepository } from "../repositories/customer-group" import { FindConfig } from "../types/common" +import { formatException } from "../utils/exception-formatter" import { CustomerGroupUpdate, FilterableCustomerGroupProps, @@ -12,21 +14,33 @@ import { type CustomerGroupConstructorProps = { manager: EntityManager customerGroupRepository: typeof CustomerGroupRepository + customerService: CustomerService } + +/** + * Provides layer to manipulate discounts. + * @implements {BaseService} + */ class CustomerGroupService extends BaseService { private manager_: EntityManager private customerGroupRepository_: typeof CustomerGroupRepository + private customerService_: CustomerService + constructor({ manager, customerGroupRepository, + customerService, }: CustomerGroupConstructorProps) { super() this.manager_ = manager this.customerGroupRepository_ = customerGroupRepository + + /** @private @const {CustomerGroupService} */ + this.customerService_ = customerService } withTransaction(transactionManager: EntityManager): CustomerGroupService { @@ -37,6 +51,7 @@ class CustomerGroupService extends BaseService { const cloned = new CustomerGroupService({ manager: transactionManager, customerGroupRepository: this.customerGroupRepository_, + customerService: this.customerService_, }) cloned.transactionManager_ = transactionManager @@ -56,7 +71,7 @@ class CustomerGroupService extends BaseService { if (!customerGroup) { throw new MedusaError( MedusaError.Types.NOT_FOUND, - `CustomerGroup with ${id} was not found` + `CustomerGroup with id ${id} was not found` ) } @@ -89,11 +104,60 @@ class CustomerGroupService extends BaseService { }) } + /** + * Add a batch of customers to a customer group at once + * @param {string} id id of the customer group to add customers to + * @param {string[]} customerIds customer id's to add to the group + * @return {Promise} the customer group after insertion + */ + async addCustomers( + id: string, + customerIds: string | string[] + ): Promise { + let ids: string[] + if (typeof customerIds === "string") { + ids = [customerIds] + } else { + ids = customerIds + } + + return this.atomicPhase_( + async (manager) => { + const cgRepo: CustomerGroupRepository = manager.getCustomRepository( + this.customerGroupRepository_ + ) + return await cgRepo.addCustomers(id, ids) + }, + async (error) => { + if (error.code === "23503") { + await this.retrieve(id) + + const existingCustomers = await this.customerService_.list({ + id: ids, + }) + + const nonExistingCustomers = ids.filter( + (cId) => existingCustomers.findIndex((el) => el.id === cId) === -1 + ) + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `The following customer ids do not exist: ${JSON.stringify( + nonExistingCustomers.join(", ") + )}` + ) + } + throw formatException(error) + } + ) + } + /** * Update a customer group. * * @param {string} customerGroupId - id of the customer group * @param {CustomerGroupUpdate} update - customer group partial data + * @returns resulting customer group */ async update( customerGroupId: string, diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.js index 97569c21bb..ee4f25f05c 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.js @@ -4,6 +4,7 @@ import { MedusaError, Validator } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" import Scrypt from "scrypt-kdf" import { Brackets, ILike } from "typeorm" +import { formatException } from "../utils/exception-formatter" /** * Provides layer to manipulate customers. @@ -21,7 +22,6 @@ class CustomerService extends BaseService { customerRepository, eventBusService, addressRepository, - customerGroupService, }) { super() @@ -36,8 +36,6 @@ class CustomerService extends BaseService { /** @private @const {AddressRepository} */ this.addressRepository_ = addressRepository - - this.customerGroupService_ = customerGroupService } withTransaction(transactionManager) { @@ -63,9 +61,7 @@ class CustomerService extends BaseService { * @return {string} the validated email */ validateEmail_(email) { - const schema = Validator.string() - .email() - .required() + const schema = Validator.string().email().required() const { value, error } = schema.validate(email) if (error) { throw new MedusaError( @@ -382,59 +378,63 @@ class CustomerService extends BaseService { * @return {Promise} resolves to the update result. */ async update(customerId, update) { - return this.atomicPhase_(async (manager) => { - const customerRepository = manager.getCustomRepository( - this.customerRepository_ - ) - const addrRepo = manager.getCustomRepository(this.addressRepository_) + return this.atomicPhase_( + async (manager) => { + const customerRepository = manager.getCustomRepository( + this.customerRepository_ + ) + const addrRepo = manager.getCustomRepository(this.addressRepository_) - const customer = await this.retrieve(customerId) + const customer = await this.retrieve(customerId) - const { - email, - password, - metadata, - billing_address, - billing_address_id, - groups, - ...rest - } = update + const { + email, + password, + metadata, + billing_address, + billing_address_id, + groups, + ...rest + } = update - if (metadata) { - customer.metadata = this.setMetadata_(customer, metadata) - } - - if (email) { - customer.email = this.validateEmail_(email) - } - - if ("billing_address_id" in update || "billing_address" in update) { - const address = billing_address_id || billing_address - if (typeof address !== "undefined") { - await this.updateBillingAddress_(customer, address, addrRepo) + if (metadata) { + customer.metadata = this.setMetadata_(customer, metadata) } + + if (email) { + customer.email = this.validateEmail_(email) + } + + if ("billing_address_id" in update || "billing_address" in update) { + const address = billing_address_id || billing_address + if (typeof address !== "undefined") { + await this.updateBillingAddress_(customer, address, addrRepo) + } + } + + for (const [key, value] of Object.entries(rest)) { + customer[key] = value + } + + if (password) { + customer.password_hash = await this.hashPassword_(password) + } + + if (groups) { + customer.groups = groups + } + + const updated = await customerRepository.save(customer) + + await this.eventBus_ + .withTransaction(manager) + .emit(CustomerService.Events.UPDATED, updated) + return updated + }, + async (error) => { + throw formatException(error) } - - for (const [key, value] of Object.entries(rest)) { - customer[key] = value - } - - if (password) { - customer.password_hash = await this.hashPassword_(password) - } - - if (groups) { - const id = groups.map((g) => g.id) - customer.groups = await this.customerGroupService_.list({ id }) - } - - const updated = await customerRepository.save(customer) - - await this.eventBus_ - .withTransaction(manager) - .emit(CustomerService.Events.UPDATED, updated) - return updated - }) + ) } /** diff --git a/packages/medusa/src/services/discount.js b/packages/medusa/src/services/discount.js index f28b0a5a1e..b0996ec788 100644 --- a/packages/medusa/src/services/discount.js +++ b/packages/medusa/src/services/discount.js @@ -2,6 +2,7 @@ import { BaseService } from "medusa-interfaces" import { Validator, MedusaError } from "medusa-core-utils" import { parse, toSeconds } from "iso8601-duration" import { Brackets, ILike } from "typeorm" +import { formatException } from "../utils/exception-formatter" /** * Provides layer to manipulate discounts. @@ -182,24 +183,27 @@ class DiscountService extends BaseService { "Fixed discounts can have one region" ) } - - if (discount.regions) { - discount.regions = await Promise.all( - discount.regions.map((regionId) => - this.regionService_.withTransaction(manager).retrieve(regionId) + try { + if (discount.regions) { + discount.regions = await Promise.all( + discount.regions.map((regionId) => + this.regionService_.withTransaction(manager).retrieve(regionId) + ) ) - ) + } + + const discountRule = await ruleRepo.create(validatedRule) + const createdDiscountRule = await ruleRepo.save(discountRule) + + discount.code = discount.code.toUpperCase() + discount.rule = createdDiscountRule + + const created = await discountRepo.create(discount) + const result = await discountRepo.save(created) + return result + } catch (error) { + throw formatException(error) } - - const discountRule = await ruleRepo.create(validatedRule) - const createdDiscountRule = await ruleRepo.save(discountRule) - - discount.code = discount.code.toUpperCase() - discount.rule = createdDiscountRule - - const created = await discountRepo.create(discount) - const result = await discountRepo.save(created) - return result }) } diff --git a/packages/medusa/src/services/product-collection.js b/packages/medusa/src/services/product-collection.js index faf8d2a01e..53422a5e2f 100644 --- a/packages/medusa/src/services/product-collection.js +++ b/packages/medusa/src/services/product-collection.js @@ -1,6 +1,7 @@ import { MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" import { Brackets, ILike } from "typeorm" +import { formatException } from "../utils/exception-formatter" /** * Provides layer to manipulate product collections. @@ -106,8 +107,12 @@ class ProductCollectionService extends BaseService { this.productCollectionRepository_ ) - const productCollection = collectionRepo.create(collection) - return collectionRepo.save(productCollection) + try { + const productCollection = await collectionRepo.create(collection) + return await collectionRepo.save(productCollection) + } catch (error) { + throw formatException(error) + } }) } @@ -166,13 +171,17 @@ class ProductCollectionService extends BaseService { return this.atomicPhase_(async (manager) => { const productRepo = manager.getCustomRepository(this.productRepository_) - const { id } = await this.retrieve(collectionId, { select: ["id"] }) + try { + const { id } = await this.retrieve(collectionId, { select: ["id"] }) - await productRepo.bulkAddToCollection(productIds, id) + await productRepo.bulkAddToCollection(productIds, id) - return await this.retrieve(id, { - relations: ["products"], - }) + return await this.retrieve(id, { + relations: ["products"], + }) + } catch (error) { + throw formatException(error) + } }) } diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index aa2cb1b6d5..1185e3b054 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -1,6 +1,7 @@ import { MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" import { Brackets } from "typeorm" +import { formatException } from "../utils/exception-formatter" /** * Provides layer to manipulate products. @@ -378,38 +379,44 @@ class ProductService extends BaseService { rest.discountable = false } - let product = productRepo.create(rest) + try { + let product = productRepo.create(rest) - if (images) { - product.images = await this.upsertImages_(images) - } + if (images) { + product.images = await this.upsertImages_(images) + } - if (tags) { - product.tags = await this.upsertProductTags_(tags) - } + if (tags) { + product.tags = await this.upsertProductTags_(tags) + } - if (typeof type !== `undefined`) { - product.type_id = await this.upsertProductType_(type) - } + if (typeof type !== `undefined`) { + product.type_id = await this.upsertProductType_(type) + } - product = await productRepo.save(product) + product = await productRepo.save(product) - product.options = await Promise.all( - options.map(async (o) => { - const res = optionRepo.create({ ...o, product_id: product.id }) - await optionRepo.save(res) - return res + product.options = await Promise.all( + options.map(async (o) => { + const res = optionRepo.create({ ...o, product_id: product.id }) + await optionRepo.save(res) + return res + }) + ) + + const result = await this.retrieve(product.id, { + relations: ["options"], }) - ) - const result = await this.retrieve(product.id, { relations: ["options"] }) - - await this.eventBus_ - .withTransaction(manager) - .emit(ProductService.Events.CREATED, { - id: result.id, - }) - return result + await this.eventBus_ + .withTransaction(manager) + .emit(ProductService.Events.CREATED, { + id: result.id, + }) + return result + } catch (error) { + throw formatException(error) + } }) } diff --git a/packages/medusa/src/types/customer-groups.ts b/packages/medusa/src/types/customer-groups.ts index 607d5056ae..aa7d98ccdb 100644 --- a/packages/medusa/src/types/customer-groups.ts +++ b/packages/medusa/src/types/customer-groups.ts @@ -13,6 +13,7 @@ export class CustomerGroupsBatchCustomer { @IsString() id: string } + export class CustomerGroupUpdate { name?: string metadata?: object diff --git a/packages/medusa/src/utils/exception-formatter.ts b/packages/medusa/src/utils/exception-formatter.ts new file mode 100644 index 0000000000..1243c5caf4 --- /dev/null +++ b/packages/medusa/src/utils/exception-formatter.ts @@ -0,0 +1,41 @@ +import { MedusaError } from "medusa-core-utils" + +export enum PostgresError { + DUPLICATE_ERROR = "23505", + FOREIGN_KEY_ERROR = "23503", +} +export const formatException = (err): Error => { + switch (err.code) { + case PostgresError.DUPLICATE_ERROR: + return new MedusaError( + MedusaError.Types.DUPLICATE_ERROR, + `${err.table.charAt(0).toUpperCase()}${err.table.slice( + 1 + )} with ${err.detail + .slice(4) + .replace(/[()=]/g, (s) => (s === "=" ? " " : ""))}` + ) + case PostgresError.FOREIGN_KEY_ERROR: { + const matches = + /Key \(([\w-\d]+)\)=\(([\w-\d]+)\) is not present in table "(\w+)"/g.exec( + err.detail + ) + + if (matches?.length !== 4) { + return new MedusaError( + MedusaError.Types.NOT_FOUND, + JSON.stringify(matches) + ) + } + + return new MedusaError( + MedusaError.Types.NOT_FOUND, + `${matches[3]?.charAt(0).toUpperCase()}${matches[3]?.slice(1)} with ${ + matches[1] + } ${matches[2]} does not exist.` + ) + } + default: + return err + } +}