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 <skrindom@gmail.com> * 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 <skrindom@gmail.com> * Update packages/medusa/src/api/routes/admin/customer-groups/add-customers-batch.ts Co-authored-by: Sebastian Rindom <skrindom@gmail.com> * 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 <skrindom@gmail.com> * 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 <skrindom@gmail.com>
This commit is contained in:
@@ -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`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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") })
|
||||
|
||||
@@ -3,6 +3,27 @@ import { CustomerGroup } from "../models/customer-group"
|
||||
|
||||
@EntityRepository(CustomerGroup)
|
||||
export class CustomerGroupRepository extends Repository<CustomerGroup> {
|
||||
async addCustomers(
|
||||
groupId: string,
|
||||
customerIds: string[]
|
||||
): Promise<CustomerGroup> {
|
||||
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[]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<CustomerGroup>} the customer group after insertion
|
||||
*/
|
||||
async addCustomers(
|
||||
id: string,
|
||||
customerIds: string | string[]
|
||||
): Promise<CustomerGroup> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export class CustomerGroupsBatchCustomer {
|
||||
@IsString()
|
||||
id: string
|
||||
}
|
||||
|
||||
export class CustomerGroupUpdate {
|
||||
name?: string
|
||||
metadata?: object
|
||||
|
||||
41
packages/medusa/src/utils/exception-formatter.ts
Normal file
41
packages/medusa/src/utils/exception-formatter.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user