diff --git a/integration-tests/plugins/__tests__/customer-group/admin/batch-add-customers.ts b/integration-tests/plugins/__tests__/customer-group/admin/batch-add-customers.ts new file mode 100644 index 0000000000000..ed0efa1cce273 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/batch-add-customers.ts @@ -0,0 +1,85 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/customer-groups/:id/customers/batch", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should batch add customers to a group", async () => { + const api = useApi() as any + + const group = await customerModuleService.createCustomerGroup({ + name: "VIP", + }) + const customers = await customerModuleService.create([ + { + first_name: "Test", + last_name: "Test", + }, + { + first_name: "Test2", + last_name: "Test2", + }, + { + first_name: "Test3", + last_name: "Test3", + }, + ]) + + const response = await api.post( + `/admin/customer-groups/${group.id}/customers/batch`, + { + customer_ids: customers.map((c) => ({ id: c.id })), + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const updatedGroup = await customerModuleService.retrieveCustomerGroup( + group.id, + { + relations: ["customers"], + } + ) + expect(updatedGroup.customers?.length).toEqual(3) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer-group/admin/batch-remove-customers.ts b/integration-tests/plugins/__tests__/customer-group/admin/batch-remove-customers.ts new file mode 100644 index 0000000000000..a366dcd05fbb7 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/batch-remove-customers.ts @@ -0,0 +1,89 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("DELETE /admin/customer-groups/:id/customers/remove", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should batch delete customers from a group", async () => { + const api = useApi() as any + + const group = await customerModuleService.createCustomerGroup({ + name: "VIP", + }) + const customers = await customerModuleService.create([ + { + first_name: "Test", + last_name: "Test", + }, + { + first_name: "Test2", + last_name: "Test2", + }, + { + first_name: "Test3", + last_name: "Test3", + }, + ]) + + await customerModuleService.addCustomerToGroup( + customers.map((c) => ({ customer_id: c.id, customer_group_id: group.id })) + ) + + const response = await api.post( + `/admin/customer-groups/${group.id}/customers/remove`, + { + customer_ids: customers.map((c) => ({ id: c.id })), + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const updatedGroup = await customerModuleService.retrieveCustomerGroup( + group.id, + { + relations: ["customers"], + } + ) + expect(updatedGroup.customers?.length).toEqual(0) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer-group/admin/list-customer-group-customers.ts b/integration-tests/plugins/__tests__/customer-group/admin/list-customer-group-customers.ts new file mode 100644 index 0000000000000..453170e8e12d7 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/list-customer-group-customers.ts @@ -0,0 +1,88 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("GET /admin/customer-groups/:id/customers", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should get all customer groups and its count", async () => { + const group = await customerModuleService.createCustomerGroup({ + name: "Test", + }) + + const customers = await customerModuleService.create([ + { + first_name: "Test", + last_name: "Test", + }, + { + first_name: "Test2", + last_name: "Test2", + }, + ]) + + // add to group + + await customerModuleService.addCustomerToGroup( + customers.map((c) => ({ customer_id: c.id, customer_group_id: group.id })) + ) + + const api = useApi() as any + + const response = await api.get( + `/admin/customer-groups/${group.id}/customers`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(response.data.customers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: customers[0].id, + }), + expect.objectContaining({ + id: customers[1].id, + }), + ]) + ) + }) +}) diff --git a/packages/core-flows/src/customer-group/steps/create-customer-group-customers.ts b/packages/core-flows/src/customer-group/steps/create-customer-group-customers.ts new file mode 100644 index 0000000000000..cb31cf738d9ab --- /dev/null +++ b/packages/core-flows/src/customer-group/steps/create-customer-group-customers.ts @@ -0,0 +1,29 @@ +import { GroupCustomerPair, ICustomerModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const createCustomerGroupCustomersStepId = + "create-customer-group-customers" +export const createCustomerGroupCustomersStep = createStep( + createCustomerGroupCustomersStepId, + async (data: GroupCustomerPair[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const groupPairs = await service.addCustomerToGroup(data) + + return new StepResponse(groupPairs, data) + }, + async (groupPairs, { container }) => { + if (!groupPairs?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.removeCustomerFromGroup(groupPairs) + } +) diff --git a/packages/core-flows/src/customer-group/steps/delete-customer-group-customers.ts b/packages/core-flows/src/customer-group/steps/delete-customer-group-customers.ts new file mode 100644 index 0000000000000..b47aae66aafd7 --- /dev/null +++ b/packages/core-flows/src/customer-group/steps/delete-customer-group-customers.ts @@ -0,0 +1,28 @@ +import { GroupCustomerPair, ICustomerModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const deleteCustomerGroupCustomersStepId = + "delete-customer-group-customers" +export const deleteCustomerGroupCustomersStep = createStep( + deleteCustomerGroupCustomersStepId, + async (data: GroupCustomerPair[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.removeCustomerFromGroup(data) + + return new StepResponse(void 0, data) + }, + async (groupPairs, { container }) => { + if (!groupPairs?.length) { + return + } + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.addCustomerToGroup(groupPairs) + } +) diff --git a/packages/core-flows/src/customer-group/steps/index.ts b/packages/core-flows/src/customer-group/steps/index.ts index 2d99ae94fa022..196a19bd736da 100644 --- a/packages/core-flows/src/customer-group/steps/index.ts +++ b/packages/core-flows/src/customer-group/steps/index.ts @@ -1,3 +1,5 @@ export * from "./update-customer-groups" export * from "./delete-customer-groups" export * from "./create-customer-groups" +export * from "./create-customer-group-customers" +export * from "./delete-customer-group-customers" diff --git a/packages/core-flows/src/customer-group/workflows/create-customer-group-customers.ts b/packages/core-flows/src/customer-group/workflows/create-customer-group-customers.ts new file mode 100644 index 0000000000000..6a3da2d856f75 --- /dev/null +++ b/packages/core-flows/src/customer-group/workflows/create-customer-group-customers.ts @@ -0,0 +1,14 @@ +import { GroupCustomerPair } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createCustomerGroupCustomersStep } from "../steps" + +type WorkflowInput = { groupCustomers: GroupCustomerPair[] } + +export const createCustomerGroupCustomersWorkflowId = + "create-customer-group-customers" +export const createCustomerGroupCustomersWorkflow = createWorkflow( + createCustomerGroupCustomersWorkflowId, + (input: WorkflowData): WorkflowData<{ id: string }[]> => { + return createCustomerGroupCustomersStep(input.groupCustomers) + } +) diff --git a/packages/core-flows/src/customer-group/workflows/delete-customer-group-customers.ts b/packages/core-flows/src/customer-group/workflows/delete-customer-group-customers.ts new file mode 100644 index 0000000000000..7696a36cc5f69 --- /dev/null +++ b/packages/core-flows/src/customer-group/workflows/delete-customer-group-customers.ts @@ -0,0 +1,14 @@ +import { GroupCustomerPair } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteCustomerGroupCustomersStep } from "../steps" + +type WorkflowInput = { groupCustomers: GroupCustomerPair[] } + +export const deleteCustomerGroupCustomersWorkflowId = + "delete-customer-group-customers" +export const deleteCustomerGroupCustomersWorkflow = createWorkflow( + deleteCustomerGroupCustomersWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteCustomerGroupCustomersStep(input.groupCustomers) + } +) diff --git a/packages/core-flows/src/customer-group/workflows/index.ts b/packages/core-flows/src/customer-group/workflows/index.ts index 2d99ae94fa022..196a19bd736da 100644 --- a/packages/core-flows/src/customer-group/workflows/index.ts +++ b/packages/core-flows/src/customer-group/workflows/index.ts @@ -1,3 +1,5 @@ export * from "./update-customer-groups" export * from "./delete-customer-groups" export * from "./create-customer-groups" +export * from "./create-customer-group-customers" +export * from "./delete-customer-group-customers" diff --git a/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/batch/route.ts b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/batch/route.ts new file mode 100644 index 0000000000000..d766dfb6e90aa --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/batch/route.ts @@ -0,0 +1,27 @@ +import { createCustomerGroupCustomersWorkflow } from "@medusajs/core-flows" +import { MedusaRequest, MedusaResponse } from "../../../../../../types/routing" +import { AdminPostCustomerGroupsGroupCustomersBatchReq } from "../../../validators" + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const { id } = req.params + const { customer_ids } = + req.validatedBody as AdminPostCustomerGroupsGroupCustomersBatchReq + + const createCustomers = createCustomerGroupCustomersWorkflow(req.scope) + + const { result, errors } = await createCustomers.run({ + input: { + groupCustomers: customer_ids.map((c) => ({ + customer_id: c.id, + customer_group_id: id, + })), + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ customer_group_customers: result }) +} diff --git a/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/remove/route.ts b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/remove/route.ts new file mode 100644 index 0000000000000..8e8647f733f67 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/remove/route.ts @@ -0,0 +1,31 @@ +import { deleteCustomerGroupCustomersWorkflow } from "@medusajs/core-flows" + +import { MedusaRequest, MedusaResponse } from "../../../../../../types/routing" +import { AdminPostCustomerGroupsGroupCustomersBatchReq } from "../../../validators" + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const { id } = req.params + const { customer_ids } = + req.validatedBody as AdminPostCustomerGroupsGroupCustomersBatchReq + + const deleteCustomers = deleteCustomerGroupCustomersWorkflow(req.scope) + + const { errors } = await deleteCustomers.run({ + input: { + groupCustomers: customer_ids.map((c) => ({ + customer_id: c.id, + customer_group_id: id, + })), + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + object: "customer_group_customers", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/route.ts b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/route.ts new file mode 100644 index 0000000000000..84a7fad2537c8 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/route.ts @@ -0,0 +1,25 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const { id } = req.params + + const service = req.scope.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const [customers, count] = await service.listAndCount( + { ...req.filterableFields, groups: id }, + req.listConfig + ) + + const { offset, limit } = req.validatedQuery + + res.json({ + count, + customers, + offset, + limit, + }) +} diff --git a/packages/medusa/src/api-v2/admin/customer-groups/middlewares.ts b/packages/medusa/src/api-v2/admin/customer-groups/middlewares.ts index d1ca416a9fc5b..e8faf2db43f45 100644 --- a/packages/medusa/src/api-v2/admin/customer-groups/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/customer-groups/middlewares.ts @@ -1,24 +1,18 @@ -import { MedusaV2Flag } from "@medusajs/utils" - -import { - isFeatureFlagEnabled, - transformBody, - transformQuery, -} from "../../../api/middlewares" +import { transformBody, transformQuery } from "../../../api/middlewares" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import * as QueryConfig from "./query-config" +import { listTransformQueryConfig as customersListTransformQueryConfig } from "../customers/query-config" import { AdminGetCustomerGroupsParams, AdminGetCustomerGroupsGroupParams, AdminPostCustomerGroupsReq, AdminPostCustomerGroupsGroupReq, + AdminGetCustomerGroupsGroupCustomersParams, + AdminPostCustomerGroupsGroupCustomersBatchReq, + AdminDeleteCustomerGroupsGroupCustomersBatchReq, } from "./validators" export const adminCustomerGroupRoutesMiddlewares: MiddlewareRoute[] = [ - { - matcher: "/admin/customer-groups*", - middlewares: [isFeatureFlagEnabled(MedusaV2Flag.key)], - }, { method: ["GET"], matcher: "/admin/customer-groups", @@ -49,4 +43,26 @@ export const adminCustomerGroupRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/customer-groups/:id", middlewares: [transformBody(AdminPostCustomerGroupsGroupReq)], }, + { + method: ["GET"], + matcher: "/admin/customer-groups/:id/customers", + middlewares: [ + transformQuery( + AdminGetCustomerGroupsGroupCustomersParams, + customersListTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/customer-groups/:id/customers/batch", + middlewares: [transformBody(AdminPostCustomerGroupsGroupCustomersBatchReq)], + }, + { + method: ["POST"], + matcher: "/admin/customer-groups/:id/customers/remove", + middlewares: [ + transformBody(AdminDeleteCustomerGroupsGroupCustomersBatchReq), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/customer-groups/validators.ts b/packages/medusa/src/api-v2/admin/customer-groups/validators.ts index a0f5a24283262..e318d6a38a2ca 100644 --- a/packages/medusa/src/api-v2/admin/customer-groups/validators.ts +++ b/packages/medusa/src/api-v2/admin/customer-groups/validators.ts @@ -1,5 +1,5 @@ import { OperatorMap } from "@medusajs/types" -import { Type } from "class-transformer" +import { Transform, Type } from "class-transformer" import { IsNotEmpty, IsOptional, @@ -8,6 +8,7 @@ import { } from "class-validator" import { FindParams, extendedFindParamsMixin } from "../../../types/common" import { OperatorMapValidator } from "../../../types/validators/operator-map" +import { IsType } from "../../../utils" export class AdminGetCustomerGroupsGroupParams extends FindParams {} @@ -112,3 +113,62 @@ export class AdminPostCustomerGroupsGroupReq { @IsOptional() name?: string } + +export class AdminGetCustomerGroupsGroupCustomersParams extends extendedFindParamsMixin( + { + limit: 100, + offset: 0, + } +) { + @IsOptional() + @IsString({ each: true }) + id?: string | string[] + + @IsOptional() + @IsType([String, [String], OperatorMapValidator]) + email?: string | string[] | OperatorMap + + @IsOptional() + @IsString({ each: true }) + company_name?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + first_name?: string | string[] | OperatorMap | null + + @IsOptional() + @IsType([String, [String], OperatorMapValidator]) + @Transform(({ value }) => (value === "null" ? null : value)) + last_name?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + created_by?: string | string[] | null + + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + created_at?: OperatorMap + + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + updated_at?: OperatorMap +} + +class CustomerGroupsBatchCustomer { + @IsString() + id: string +} + +export class AdminDeleteCustomerGroupsGroupCustomersBatchReq { + @ValidateNested({ each: true }) + @Type(() => CustomerGroupsBatchCustomer) + customer_ids: CustomerGroupsBatchCustomer[] +} + +export class AdminPostCustomerGroupsGroupCustomersBatchReq { + @ValidateNested({ each: true }) + @Type(() => CustomerGroupsBatchCustomer) + customer_ids: CustomerGroupsBatchCustomer[] +}