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 <skrindom@gmail.com>
This commit is contained in:
Philip Korsholm
2022-02-18 09:58:54 +01:00
committed by olivermrbl
parent 8a2e4f74ff
commit 3b878bc5dd
16 changed files with 440 additions and 11 deletions

View File

@@ -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."
)
})
})
})
})

View File

@@ -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",
})
}

View File

@@ -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": {

View File

@@ -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")
})
})
})

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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"

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class customerGroups1644943746861 implements MigrationInterface {
name = "customerGroups1644943746861"
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`)
}
}

View File

@@ -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}`
}
}

View File

@@ -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}`
}

View File

@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { CustomerGroup } from "../models/customer-group"
@EntityRepository(CustomerGroup)
export class CustomerGroupRepository extends Repository<CustomerGroup> {}

View File

@@ -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

View File

@@ -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<CustomerGroup>} group - the customer group to create
* @return {Promise} the result of the create operation
*/
async create(group: DeepPartial<CustomerGroup>): Promise<CustomerGroup> {
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

View File

@@ -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)

View File

@@ -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"