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:
committed by
olivermrbl
parent
8a2e4f74ff
commit
3b878bc5dd
88
integration-tests/api/__tests__/admin/customer-groups.js
Normal file
88
integration-tests/api/__tests__/admin/customer-groups.js
Normal 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."
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`)
|
||||
}
|
||||
}
|
||||
60
packages/medusa/src/models/customer-group.ts
Normal file
60
packages/medusa/src/models/customer-group.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
5
packages/medusa/src/repositories/customer-group.ts
Normal file
5
packages/medusa/src/repositories/customer-group.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { EntityRepository, Repository } from "typeorm"
|
||||
import { CustomerGroup } from "../models/customer-group"
|
||||
|
||||
@EntityRepository(CustomerGroup)
|
||||
export class CustomerGroupRepository extends Repository<CustomerGroup> {}
|
||||
15
packages/medusa/src/services/__mocks__/customer-group.js
Normal file
15
packages/medusa/src/services/__mocks__/customer-group.js
Normal 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
|
||||
69
packages/medusa/src/services/customer-group.ts
Normal file
69
packages/medusa/src/services/customer-group.ts
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user