From b16976a6f4d27183dab9cdd78ef88ef828df9724 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Fri, 18 Feb 2022 09:58:54 +0100 Subject: [PATCH] Feat: Create customer group (#1074) * 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 * duplicate error handling * ducplicate name integration test * add jsdoc * customergroup not customer * pr feedback * pipeline test * fix weird merge Co-authored-by: Sebastian Rindom --- .../api/__tests__/admin/customer-groups.js | 88 +++++++++++++++++++ .../api/helpers/customer-seeder.js | 7 +- packages/medusa-interfaces/.babelrc | 3 +- .../__tests__/create-customer-group.ts | 65 ++++++++++++++ .../customer-groups/create-customer-group.ts | 45 ++++++++++ .../api/routes/admin/customer-groups/index.ts | 25 ++++++ packages/medusa/src/api/routes/admin/index.js | 2 + packages/medusa/src/index.js | 1 + .../1644943746861-customer_groups.ts | 44 ++++++++++ packages/medusa/src/models/customer-group.ts | 60 +++++++++++++ packages/medusa/src/models/customer.ts | 20 +++-- .../medusa/src/repositories/customer-group.ts | 5 ++ .../src/services/__mocks__/customer-group.js | 15 ++++ .../medusa/src/services/customer-group.ts | 69 +++++++++++++++ packages/medusa/src/services/customer.js | 1 + packages/medusa/src/services/index.ts | 1 + 16 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/customer-groups.js create mode 100644 packages/medusa/src/api/routes/admin/customer-groups/__tests__/create-customer-group.ts create mode 100644 packages/medusa/src/api/routes/admin/customer-groups/create-customer-group.ts create mode 100644 packages/medusa/src/api/routes/admin/customer-groups/index.ts create mode 100644 packages/medusa/src/migrations/1644943746861-customer_groups.ts create mode 100644 packages/medusa/src/models/customer-group.ts create mode 100644 packages/medusa/src/repositories/customer-group.ts create mode 100644 packages/medusa/src/services/__mocks__/customer-group.js create mode 100644 packages/medusa/src/services/customer-group.ts diff --git a/integration-tests/api/__tests__/admin/customer-groups.js b/integration-tests/api/__tests__/admin/customer-groups.js new file mode 100644 index 0000000000..0561f8e72d --- /dev/null +++ b/integration-tests/api/__tests__/admin/customer-groups.js @@ -0,0 +1,88 @@ +const path = require("path") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { useDb, initDb } = require("../../../helpers/use-db") + +const customerSeeder = require("../../helpers/customer-seeder") +const adminSeeder = require("../../helpers/admin-seeder") + +jest.setTimeout(30000) + +describe("/admin/customer-groups", () => { + 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("POST /admin/customer-groups", () => { + 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("creates customer group", async () => { + const api = useApi() + + const payload = { + name: "test group", + } + + const response = await api.post("/admin/customer-groups", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + + expect(response.status).toEqual(200) + expect(response.data.customerGroup).toEqual( + expect.objectContaining({ + name: "test group", + }) + ) + }) + + it("Fails to create duplciate customer group", async () => { + expect.assertions(3) + const api = useApi() + + const payload = { + name: "vip-customers", + } + + await api + .post("/admin/customer-groups", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + expect(err.response.status).toEqual(402) + expect(err.response.data.type).toEqual("duplicate_error") + expect(err.response.data.message).toEqual( + "Key (name)=(vip-customers) already exists." + ) + }) + }) + }) +}) diff --git a/integration-tests/api/helpers/customer-seeder.js b/integration-tests/api/helpers/customer-seeder.js index ff27341312..c0db241b4e 100644 --- a/integration-tests/api/helpers/customer-seeder.js +++ b/integration-tests/api/helpers/customer-seeder.js @@ -1,4 +1,4 @@ -const { Customer, Address } = require("@medusajs/medusa") +const { Customer, Address, CustomerGroup } = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { const manager = connection.manager @@ -35,4 +35,9 @@ module.exports = async (connection, data = {}) => { email: "test4@email.com", has_account: true, }) + + await manager.insert(CustomerGroup, { + id: "customer-group-1", + name: "vip-customers", + }) } diff --git a/packages/medusa-interfaces/.babelrc b/packages/medusa-interfaces/.babelrc index 5c12149795..3d2c30081f 100644 --- a/packages/medusa-interfaces/.babelrc +++ b/packages/medusa-interfaces/.babelrc @@ -1,7 +1,8 @@ { "plugins": [ "@babel/plugin-proposal-class-properties", - "@babel/plugin-transform-instanceof" + "@babel/plugin-transform-instanceof", + ["@babel/plugin-transform-runtime", { "regenerator": true }] ], "presets": ["@babel/preset-env"], "env": { diff --git a/packages/medusa/src/api/routes/admin/customer-groups/__tests__/create-customer-group.ts b/packages/medusa/src/api/routes/admin/customer-groups/__tests__/create-customer-group.ts new file mode 100644 index 0000000000..2b239d1cee --- /dev/null +++ b/packages/medusa/src/api/routes/admin/customer-groups/__tests__/create-customer-group.ts @@ -0,0 +1,65 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { CustomerGroupServiceMock } from "../../../../../services/__mocks__/customer-group" + +describe("POST /customer-groups", () => { + describe("successfully calls create customer group", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", `/admin/customer-groups`, { + payload: { + name: "test group", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls CustomerGroupService create", () => { + expect(CustomerGroupServiceMock.create).toHaveBeenCalledTimes(1) + expect(CustomerGroupServiceMock.create).toHaveBeenCalledWith({ + name: "test group", + }) + }) + }) + + describe("fails if no name is provided for the group ", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", `/admin/customer-groups`, { + payload: {}, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns descriptive error that name is missing", () => { + expect(subject.body.type).toEqual("invalid_data") + expect(subject.body.message).toEqual("name must be a string") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/customer-groups/create-customer-group.ts b/packages/medusa/src/api/routes/admin/customer-groups/create-customer-group.ts new file mode 100644 index 0000000000..d02ced32c5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/customer-groups/create-customer-group.ts @@ -0,0 +1,45 @@ +import { IsObject, IsOptional, IsString } from "class-validator" +import { CustomerGroupService } from "../../../../services" +import { validator } from "../../../../utils/validator" + +/** + * @oas [post] /customer-groups + * operationId: "PostCustomerGroups" + * summary: "Create a CustomerGroup" + * description: "Creates a CustomerGroup." + * x-authenticated: true + * parameters: + * - (body) name=* {string} Name of the customer group + * - (body) metadata {object} Metadata for the customer. + * tags: + * - CustomerGroup + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * customerGroup: + * $ref: "#/components/schemas/customergroup" + */ + +export default async (req, res) => { + const validated = await validator(AdminPostCustomerGroupsReq, req.body) + + const customerGroupService: CustomerGroupService = req.scope.resolve( + "customerGroupService" + ) + + const customerGroup = await customerGroupService.create(validated) + res.status(200).json({ customerGroup }) +} + +export class AdminPostCustomerGroupsReq { + @IsString() + name: string + + @IsObject() + @IsOptional() + metadata?: object +} diff --git a/packages/medusa/src/api/routes/admin/customer-groups/index.ts b/packages/medusa/src/api/routes/admin/customer-groups/index.ts new file mode 100644 index 0000000000..91db467403 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/customer-groups/index.ts @@ -0,0 +1,25 @@ +import { Router } from "express" +import { CustomerGroup } from "../../../.." +import { DeleteResponse, PaginatedResponse } from "../../../../types/common" +import middlewares from "../../../middlewares" + +const route = Router() + +export default (app) => { + app.use("/customer-groups", route) + + route.post("/", middlewares.wrap(require("./create-customer-group").default)) + return app +} + +export type AdminCustomerGroupsRes = { + customer_group: CustomerGroup +} + +export type AdminCustomerGroupsDeleteRes = DeleteResponse + +export type AdminCustomerGroupsListRes = PaginatedResponse & { + customer_groups: CustomerGroup[] +} + +export * from "./create-customer-group" diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index ad1c1ee690..1e07b32c79 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -25,6 +25,7 @@ import collectionRoutes from "./collections" import productTagRoutes from "./product-tags" import notificationRoutes from "./notifications" import noteRoutes from "./notes" +import customerGroupRoutes from "./customer-groups" const route = Router() @@ -80,6 +81,7 @@ export default (app, container, config) => { productTagRoutes(route) noteRoutes(route) inviteRoutes(route) + customerGroupRoutes(route) return app } diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index 4198496d81..9d7205c29a 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -12,6 +12,7 @@ export { Country } from "./models/country" export { Currency } from "./models/currency" export { CustomShippingOption } from "./models/custom-shipping-option" export { Customer } from "./models/customer" +export { CustomerGroup } from "./models/customer-group" export { Discount } from "./models/discount" export { DiscountRule } from "./models/discount-rule" export { DraftOrder } from "./models/draft-order" diff --git a/packages/medusa/src/migrations/1644943746861-customer_groups.ts b/packages/medusa/src/migrations/1644943746861-customer_groups.ts new file mode 100644 index 0000000000..a84e7b7aa6 --- /dev/null +++ b/packages/medusa/src/migrations/1644943746861-customer_groups.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class customerGroups1644943746861 implements MigrationInterface { + name = "customerGroups1644943746861" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "customer_group" ("id" character varying NOT NULL, "name" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, "metadata" jsonb, CONSTRAINT "PK_88e7da3ff7262d9e0a35aa3664e" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_c4c3a5225a7a1f0af782c40abc" ON "customer_group" ("name") WHERE deleted_at IS NULL` + ) + await queryRunner.query( + `CREATE TABLE "customer_group_customers" ("customer_group_id" character varying NOT NULL, "customer_id" character varying NOT NULL, CONSTRAINT "PK_e28a55e34ad1e2d3df9a0ac86d3" PRIMARY KEY ("customer_group_id", "customer_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_620330964db8d2999e67b0dbe3" ON "customer_group_customers" ("customer_group_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_3c6412d076292f439269abe1a2" ON "customer_group_customers" ("customer_id") ` + ) + await queryRunner.query( + `ALTER TABLE "customer_group_customers" ADD CONSTRAINT "FK_620330964db8d2999e67b0dbe3e" FOREIGN KEY ("customer_group_id") REFERENCES "customer_group"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "customer_group_customers" ADD CONSTRAINT "FK_3c6412d076292f439269abe1a23" FOREIGN KEY ("customer_id") REFERENCES "customer"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "customer_group_customers" DROP CONSTRAINT "FK_3c6412d076292f439269abe1a23"` + ) + await queryRunner.query( + `ALTER TABLE "customer_group_customers" DROP CONSTRAINT "FK_620330964db8d2999e67b0dbe3e"` + ) + + await queryRunner.query(`DROP INDEX "IDX_3c6412d076292f439269abe1a2"`) + await queryRunner.query(`DROP INDEX "IDX_620330964db8d2999e67b0dbe3"`) + await queryRunner.query(`DROP TABLE "customer_group_customers"`) + await queryRunner.query(`DROP INDEX "IDX_c4c3a5225a7a1f0af782c40abc"`) + await queryRunner.query(`DROP TABLE "customer_group"`) + } +} diff --git a/packages/medusa/src/models/customer-group.ts b/packages/medusa/src/models/customer-group.ts new file mode 100644 index 0000000000..c7b7685064 --- /dev/null +++ b/packages/medusa/src/models/customer-group.ts @@ -0,0 +1,60 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + JoinTable, + ManyToMany, + PrimaryColumn, + UpdateDateColumn, +} from "typeorm" +import { ulid } from "ulid" +import { Customer } from ".." +import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column" + +@Entity() +export class CustomerGroup { + @PrimaryColumn() + id: string + + @Index({ unique: true, where: "deleted_at IS NULL" }) + @Column() + name: string + + @ManyToMany(() => Customer, { cascade: true }) + @JoinTable({ + name: "customer_group_customers", + joinColumn: { + name: "customer_group_id", + referencedColumnName: "id", + }, + inverseJoinColumn: { + name: "customer_id", + referencedColumnName: "id", + }, + }) + customers: Customer[] + + @CreateDateColumn({ type: resolveDbType("timestamptz") }) + created_at: Date + + @UpdateDateColumn({ type: resolveDbType("timestamptz") }) + updated_at: Date + + @DeleteDateColumn({ type: resolveDbType("timestamptz") }) + deleted_at: Date + + @DbAwareColumn({ type: "jsonb", nullable: true }) + metadata: any + + @BeforeInsert() + private beforeInsert() { + if (this.id) { + return + } + const id = ulid() + this.id = `cgrp_${id}` + } +} diff --git a/packages/medusa/src/models/customer.ts b/packages/medusa/src/models/customer.ts index ff481327fe..f3c27f9f27 100644 --- a/packages/medusa/src/models/customer.ts +++ b/packages/medusa/src/models/customer.ts @@ -10,11 +10,14 @@ import { OneToOne, OneToMany, JoinColumn, + ManyToMany, + JoinTable, } from "typeorm" import { ulid } from "ulid" import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column" import { Address } from "./address" +import { CustomerGroup } from "./customer-group" import { Order } from "./order" @Entity() @@ -40,10 +43,7 @@ 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,12 +55,12 @@ export class Customer { @Column({ default: false }) has_account: boolean - @OneToMany( - () => Order, - order => order.customer - ) + @OneToMany(() => Order, (order) => order.customer) orders: Order[] + @ManyToMany(() => CustomerGroup, { cascade: true }) + groups: CustomerGroup[] + @CreateDateColumn({ type: resolveDbType("timestamptz") }) created_at: Date @@ -75,7 +75,9 @@ export class Customer { @BeforeInsert() private beforeInsert() { - if (this.id) return + if (this.id) { + return + } const id = ulid() this.id = `cus_${id}` } diff --git a/packages/medusa/src/repositories/customer-group.ts b/packages/medusa/src/repositories/customer-group.ts new file mode 100644 index 0000000000..cb38231ef3 --- /dev/null +++ b/packages/medusa/src/repositories/customer-group.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { CustomerGroup } from "../models/customer-group" + +@EntityRepository(CustomerGroup) +export class CustomerGroupRepository extends Repository {} diff --git a/packages/medusa/src/services/__mocks__/customer-group.js b/packages/medusa/src/services/__mocks__/customer-group.js new file mode 100644 index 0000000000..db92b9a8e5 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/customer-group.js @@ -0,0 +1,15 @@ +export const CustomerGroupServiceMock = { + withTransaction: function () { + return this + }, + + create: jest.fn().mockImplementation((f) => { + return Promise.resolve(f) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return CustomerGroupServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/customer-group.ts b/packages/medusa/src/services/customer-group.ts new file mode 100644 index 0000000000..80cc6b9b82 --- /dev/null +++ b/packages/medusa/src/services/customer-group.ts @@ -0,0 +1,69 @@ +import { MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" +import { DeepPartial, EntityManager } from "typeorm" +import { CustomerGroup } from ".." +import { CustomerGroupRepository } from "../repositories/customer-group" + +type CustomerGroupConstructorProps = { + manager: EntityManager + customerGroupRepository: typeof CustomerGroupRepository +} +class CustomerGroupService extends BaseService { + private manager_: EntityManager + + private customerGroupRepository_: typeof CustomerGroupRepository + + constructor({ + manager, + customerGroupRepository, + }: CustomerGroupConstructorProps) { + super() + + this.manager_ = manager + + this.customerGroupRepository_ = customerGroupRepository + } + + withTransaction(transactionManager: EntityManager): CustomerGroupService { + if (!transactionManager) { + return this + } + + const cloned = new CustomerGroupService({ + manager: transactionManager, + customerGroupRepository: this.customerGroupRepository_, + }) + + cloned.transactionManager_ = transactionManager + + return cloned + } + + /** + * Creates a customer group with the provided data. + * @param {DeepPartial} group - the customer group to create + * @return {Promise} the result of the create operation + */ + async create(group: DeepPartial): Promise { + return this.atomicPhase_(async (manager) => { + try { + const cgRepo: CustomerGroupRepository = manager.getCustomRepository( + this.customerGroupRepository_ + ) + + const created = cgRepo.create(group) + + const result = await cgRepo.save(created) + + return result + } catch (err) { + if (err.code === "23505") { + throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, err.detail) + } + throw err + } + }) + } +} + +export default CustomerGroupService diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.js index 0cb4661c3f..40aee34bc1 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.js @@ -234,6 +234,7 @@ class CustomerService extends BaseService { const customerRepo = this.manager_.getCustomRepository( this.customerRepository_ ) + const validatedId = this.validateId_(customerId) const query = this.buildQuery_({ id: validatedId }, config) diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index 067c3d34be..9e3ff422f1 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -4,6 +4,7 @@ export { default as ClaimService } from "./claim" export { default as ClaimItemService } from "./claim-item" export { default as CustomShippingOptionService } from "./custom-shipping-option" export { default as CustomerService } from "./customer" +export { default as CustomerGroupService } from "./customer-group" export { default as DiscountService } from "./discount" export { default as DraftOrderService } from "./draft-order" export { default as EventBusService } from "./event-bus"