diff --git a/integration-tests/api/__tests__/admin/__snapshots__/customer.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/customer.js.snap index 2c77349148..a8a7e640f5 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/customer.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/customer.js.snap @@ -62,6 +62,7 @@ Object { "deleted_at": null, "email": "test1@email.com", "first_name": null, + "groups": Array [], "has_account": false, "id": "test-customer-1", "last_name": null, diff --git a/integration-tests/api/__tests__/admin/customer.js b/integration-tests/api/__tests__/admin/customer.js index 283d716894..3fd0417991 100644 --- a/integration-tests/api/__tests__/admin/customer.js +++ b/integration-tests/api/__tests__/admin/customer.js @@ -56,7 +56,7 @@ describe("/admin/customers", () => { }) expect(response.status).toEqual(200) - expect(response.data.count).toEqual(4) + expect(response.data.count).toEqual(5) expect(response.data.customers).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -228,6 +228,106 @@ describe("/admin/customers", () => { }) ) }) + + it("Correctly updates customer groups", async () => { + const api = useApi() + let response = await api + .post( + "/admin/customers/test-customer-3?expand=groups", + { + groups: [{ id: "test-group-4" }], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.customer.groups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "test-group-4", name: "test-group-4" }), + ]) + ) + + // 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 + .post( + "/admin/customers/test-customer-3?expand=groups", + { + groups: [], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.customer.groups.length).toEqual(0) + + // Adding a group to a customer with already existing groups. + + response = await api + .post( + "/admin/customers/test-customer-5?expand=groups", + { + groups: [{ id: "test-group-5" }, { id: "test-group-4" }], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.customer.groups.length).toEqual(2) + expect(response.data.customer.groups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "test-group-4", name: "test-group-4" }), + expect.objectContaining({ id: "test-group-5", name: "test-group-5" }), + ]) + ) + }) }) describe("GET /admin/customers/:id", () => { @@ -278,7 +378,7 @@ describe("/admin/customers", () => { const api = useApi() const response = await api - .get("/admin/customers/test-customer-1?expand=billing_address", { + .get("/admin/customers/test-customer-1?expand=billing_address,groups", { headers: { Authorization: "Bearer test_token", }, @@ -295,6 +395,7 @@ describe("/admin/customers", () => { created_at: expect.any(String), updated_at: expect.any(String), }, + groups: [], created_at: expect.any(String), updated_at: expect.any(String), }) diff --git a/integration-tests/api/helpers/customer-seeder.js b/integration-tests/api/helpers/customer-seeder.js index c0db241b4e..f0d795ea39 100644 --- a/integration-tests/api/helpers/customer-seeder.js +++ b/integration-tests/api/helpers/customer-seeder.js @@ -36,8 +36,24 @@ module.exports = async (connection, data = {}) => { has_account: true, }) + await manager.insert(Customer, { + id: "test-customer-5", + email: "test5@email.com", + groups: [{ id: "test-group-5", name: "test-group-5" }], + }) + await manager.insert(CustomerGroup, { id: "customer-group-1", name: "vip-customers", }) + + await manager.insert(CustomerGroup, { + id: "test-group-4", + name: "test-group-4", + }) + + await manager.insert(CustomerGroup, { + id: "test-group-5", + name: "test-group-5", + }) } diff --git a/packages/medusa/src/api/routes/admin/customers/update-customer.ts b/packages/medusa/src/api/routes/admin/customers/update-customer.ts index 11c0a8b419..32607ff6cc 100644 --- a/packages/medusa/src/api/routes/admin/customers/update-customer.ts +++ b/packages/medusa/src/api/routes/admin/customers/update-customer.ts @@ -1,7 +1,17 @@ -import { IsEmail, IsObject, IsOptional, IsString } from "class-validator" +import { + IsArray, + IsEmail, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" import { MedusaError } from "medusa-core-utils" import CustomerService from "../../../../services/customer" import { validator } from "../../../../utils/validator" +import { defaultAdminCustomersRelations } from "." +import { Type } from "class-transformer" +import { FindParams } from "../../../../types/common" /** * @oas [post] /customers/{id} @@ -31,6 +41,16 @@ import { validator } from "../../../../utils/validator" * password: * type: string * description: The Customer's password. + * groups: + * type: array + * description: A list of customer groups to which the customer belongs. + * items: + * required: + * - id + * properties: + * id: + * description: The id of a customer group + * type: string * metadata: * type: object * description: Metadata for the customer. @@ -49,27 +69,43 @@ import { validator } from "../../../../utils/validator" export default async (req, res) => { const { id } = req.params - const validated = await validator(AdminPostCustomersCustomerReq, req.body) + const validatedBody = await validator(AdminPostCustomersCustomerReq, req.body) + const validatedQuery = await validator(FindParams, req.query) const customerService: CustomerService = req.scope.resolve("customerService") let customer = await customerService.retrieve(id) - if (validated.email && customer.has_account) { + if (validatedBody.email && customer.has_account) { throw new MedusaError( MedusaError.Types.INVALID_DATA, "Email cannot be changed when the user has registered their account" ) } - await customerService.update(id, validated) + await customerService.update(id, validatedBody) + + let expandFields: string[] = [] + if (validatedQuery.expand) { + expandFields = validatedQuery.expand.split(",") + } + + const findConfig = { + relations: expandFields.length + ? expandFields + : defaultAdminCustomersRelations, + } + + customer = await customerService.retrieve(id, findConfig) - customer = await customerService.retrieve(id, { - relations: ["orders"], - }) res.status(200).json({ customer }) } +class Group { + @IsString() + id: string +} + export class AdminPostCustomersCustomerReq { @IsEmail() @IsOptional() @@ -94,4 +130,10 @@ export class AdminPostCustomersCustomerReq { @IsObject() @IsOptional() metadata?: object + + @IsArray() + @IsOptional() + @Type(() => Group) + @ValidateNested({ each: true }) + groups?: Group[] } diff --git a/packages/medusa/src/models/customer.ts b/packages/medusa/src/models/customer.ts index f3c27f9f27..7e67a86c32 100644 --- a/packages/medusa/src/models/customer.ts +++ b/packages/medusa/src/models/customer.ts @@ -43,7 +43,10 @@ export class Customer { @JoinColumn({ name: "billing_address_id" }) billing_address: Address - @OneToMany(() => Address, (address) => address.customer) + @OneToMany( + () => Address, + (address) => address.customer + ) shipping_addresses: Address[] @Column({ nullable: true, select: false }) @@ -55,9 +58,23 @@ export class Customer { @Column({ default: false }) has_account: boolean - @OneToMany(() => Order, (order) => order.customer) + @OneToMany( + () => Order, + (order) => order.customer + ) orders: Order[] + @JoinTable({ + name: "customer_group_customers", + inverseJoinColumn: { + name: "customer_group_id", + referencedColumnName: "id", + }, + joinColumn: { + name: "customer_id", + referencedColumnName: "id", + }, + }) @ManyToMany(() => CustomerGroup, { cascade: true }) groups: CustomerGroup[] diff --git a/packages/medusa/src/services/__tests__/customer.js b/packages/medusa/src/services/__tests__/customer.js index 0c3ed6dd50..319e954c17 100644 --- a/packages/medusa/src/services/__tests__/customer.js +++ b/packages/medusa/src/services/__tests__/customer.js @@ -8,6 +8,13 @@ const eventBusService = { }, } +const customerGroupService = { + withTransaction: function() { + return this + }, + list: jest.fn().mockImplementation(() => Promise.resolve()), +} + describe("CustomerService", () => { describe("retrieve", () => { const customerRepository = MockRepository({ @@ -86,7 +93,7 @@ describe("CustomerService", () => { describe("create", () => { const customerRepository = MockRepository({ - findOne: query => { + findOne: (query) => { if (query.where.email === "tony@stark.com") { return Promise.resolve({ id: IdMap.getId("exists"), @@ -162,14 +169,14 @@ describe("CustomerService", () => { describe("update", () => { const customerRepository = MockRepository({ - findOne: query => { + findOne: (query) => { return Promise.resolve({ id: IdMap.getId("ironman") }) }, }) const addressRepository = MockRepository({ - create: data => data, - save: data => Promise.resolve(data), + create: (data) => data, + save: (data) => Promise.resolve(data), }) const customerService = new CustomerService({ @@ -246,9 +253,50 @@ describe("CustomerService", () => { }) }) + describe("update customer groups", () => { + const customerRepository = MockRepository({ + findOne: (query) => { + return Promise.resolve({ id: IdMap.getId("ironman") }) + }, + }) + + const addressRepository = MockRepository({ + create: (data) => data, + save: (data) => Promise.resolve(data), + }) + + const customerGroupRepository = MockRepository({ + findByIds: jest.fn().mockImplementation(() => Promise.resolve()), + }) + + const customerService = new CustomerService({ + manager: MockManager, + addressRepository, + customerRepository, + customerGroupRepository, + customerGroupService, + eventBusService, + }) + + beforeEach(async () => { + 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" }], + }) + + expect(customerGroupService.list).toBeCalledTimes(1) + expect(customerGroupService.list).toBeCalledWith({ id: ["group-id"] }) + + expect(customerRepository.save).toBeCalledTimes(1) + }) + }) + describe("updateAddress", () => { const addressRepository = MockRepository({ - findOne: query => { + findOne: (query) => { return Promise.resolve({ id: IdMap.getId("hollywood-boulevard"), address_1: "Hollywood Boulevard 2", @@ -312,7 +360,7 @@ describe("CustomerService", () => { describe("removeAddress", () => { const addressRepository = MockRepository({ - findOne: query => { + findOne: (query) => { return Promise.resolve({ id: IdMap.getId("hollywood-boulevard"), address_1: "Hollywood Boulevard 2", @@ -345,7 +393,7 @@ describe("CustomerService", () => { describe("delete", () => { const customerRepository = MockRepository({ - findOne: query => { + findOne: (query) => { return Promise.resolve({ id: IdMap.getId("ironman") }) }, }) diff --git a/packages/medusa/src/services/customer-group.ts b/packages/medusa/src/services/customer-group.ts index 80cc6b9b82..cb1d98fa8f 100644 --- a/packages/medusa/src/services/customer-group.ts +++ b/packages/medusa/src/services/customer-group.ts @@ -3,6 +3,8 @@ import { BaseService } from "medusa-interfaces" import { DeepPartial, EntityManager } from "typeorm" import { CustomerGroup } from ".." import { CustomerGroupRepository } from "../repositories/customer-group" +import { FindConfig } from "../types/common" +import { FilterableCustomerGroupProps } from "../types/customer-groups" type CustomerGroupConstructorProps = { manager: EntityManager @@ -64,6 +66,25 @@ class CustomerGroupService extends BaseService { } }) } + + /** + * List customer groups. + * + * @param {Object} selector - the query object for find + * @param {Object} config - the config to be used for find + * @return {Promise} the result of the find operation + */ + async list( + selector: FilterableCustomerGroupProps = {}, + config: FindConfig + ): Promise { + const cgRepo: CustomerGroupRepository = this.manager_.getCustomRepository( + this.customerGroupRepository_ + ) + + const query = this.buildQuery_(selector, config) + return await cgRepo.find(query) + } } export default CustomerGroupService diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.js index 40aee34bc1..97569c21bb 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.js @@ -21,6 +21,7 @@ class CustomerService extends BaseService { customerRepository, eventBusService, addressRepository, + customerGroupService, }) { super() @@ -35,6 +36,8 @@ class CustomerService extends BaseService { /** @private @const {AddressRepository} */ this.addressRepository_ = addressRepository + + this.customerGroupService_ = customerGroupService } withTransaction(transactionManager) { @@ -60,7 +63,9 @@ 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( @@ -391,6 +396,7 @@ class CustomerService extends BaseService { metadata, billing_address, billing_address_id, + groups, ...rest } = update @@ -417,6 +423,11 @@ class CustomerService extends BaseService { 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_ diff --git a/packages/medusa/src/types/customer-groups.ts b/packages/medusa/src/types/customer-groups.ts new file mode 100644 index 0000000000..f2829205c9 --- /dev/null +++ b/packages/medusa/src/types/customer-groups.ts @@ -0,0 +1,10 @@ +import { ValidateNested } from "class-validator" +import { IsType } from "../utils/validators/is-type" + +import { StringComparisonOperator } from "./common" + +export class FilterableCustomerGroupProps { + @ValidateNested() + @IsType([String, [String], StringComparisonOperator]) + id?: string | string[] | StringComparisonOperator +}