From 259d050e53aafbdef2dee43655bc3f62a4fa7c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Fri, 25 Oct 2024 11:55:57 +0200 Subject: [PATCH] feat: customer bulk endpoint form managing customer groups (#9761) Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .../__tests__/customer/admin/customer.spec.ts | 62 +++++++++++++++++++ .../dashboard/src/hooks/api/customers.tsx | 33 ++++++++++ .../dashboard/src/i18n/translations/en.json | 6 ++ .../customer-group-section.tsx | 23 ++++--- .../add-customer-groups-form.tsx | 26 ++------ .../src/customer-group/steps/index.ts | 1 + .../steps/link-customer-groups-customer.ts | 56 +++++++++++++++++ .../src/customer-group/workflows/index.ts | 1 + .../link-customer-groups-customer.ts | 15 +++++ packages/core/js-sdk/src/admin/customer.ts | 34 ++++++++++ .../customers/[id]/customer-groups/route.ts | 34 ++++++++++ .../src/api/admin/customers/middlewares.ts | 12 ++++ 12 files changed, 271 insertions(+), 32 deletions(-) create mode 100644 packages/core/core-flows/src/customer-group/steps/link-customer-groups-customer.ts create mode 100644 packages/core/core-flows/src/customer-group/workflows/link-customer-groups-customer.ts create mode 100644 packages/medusa/src/api/admin/customers/[id]/customer-groups/route.ts diff --git a/integration-tests/http/__tests__/customer/admin/customer.spec.ts b/integration-tests/http/__tests__/customer/admin/customer.spec.ts index 26de7a682a054..9b2d1af5fabfb 100644 --- a/integration-tests/http/__tests__/customer/admin/customer.spec.ts +++ b/integration-tests/http/__tests__/customer/admin/customer.spec.ts @@ -354,6 +354,68 @@ medusaIntegrationTestRunner({ }) }) + describe("POST /admin/customers/:id/customer-groups", () => { + it("should batch add and remove customer to/from customer groups", async () => { + const group1 = ( + await api.post( + "/admin/customer-groups", + { + name: "VIP 1", + }, + adminHeaders + ) + ).data.customer_group + + const group2 = ( + await api.post( + "/admin/customer-groups", + { + name: "VIP 2", + }, + adminHeaders + ) + ).data.customer_group + + const group3 = ( + await api.post( + "/admin/customer-groups", + { + name: "VIP 3", + }, + adminHeaders + ) + ).data.customer_group + + // Add with cg endpoint so we can test remove + await api.post( + `/admin/customer-groups/${group1.id}/customers`, + { + add: [customer1.id], + }, + adminHeaders + ) + + const response = await api.post( + `/admin/customers/${customer1.id}/customer-groups?fields=groups.id`, + { + remove: [group1.id], + add: [group2.id, group3.id], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + expect(response.data.customer.groups.length).toEqual(2) + expect(response.data.customer.groups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: group2.id }), + expect.objectContaining({ id: group3.id }), + ]) + ) + }) + }) + describe("GET /admin/customers/:id", () => { it("should fetch a customer", async () => { const response = await api.get( diff --git a/packages/admin/dashboard/src/hooks/api/customers.tsx b/packages/admin/dashboard/src/hooks/api/customers.tsx index a5ff2136b4380..5f2ef7b66a5f1 100644 --- a/packages/admin/dashboard/src/hooks/api/customers.tsx +++ b/packages/admin/dashboard/src/hooks/api/customers.tsx @@ -10,6 +10,7 @@ import { import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory } from "../../lib/query-key-factory" +import { customerGroupsQueryKeys } from "./customer-groups" const CUSTOMERS_QUERY_KEY = "customers" as const export const customersQueryKeys = queryKeysFactory(CUSTOMERS_QUERY_KEY) @@ -115,3 +116,35 @@ export const useDeleteCustomer = ( ...options, }) } + +export const useBatchCustomerCustomerGroups = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminCustomerResponse, + FetchError, + HttpTypes.AdminBatchLink + > +) => { + return useMutation({ + mutationFn: (payload) => + sdk.admin.customer.batchCustomerGroups(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: customerGroupsQueryKeys.details(), + }) + queryClient.invalidateQueries({ + queryKey: customerGroupsQueryKeys.lists(), + }) + + queryClient.invalidateQueries({ + queryKey: customersQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: customersQueryKeys.details(), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index ed05537b31226..3dc170f5a70f4 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -829,6 +829,12 @@ "list": { "noRecordsMessage": "Please create a customer group first." } + }, + "removed": { + "success": "Customer removed from: {{groups}}.", + "list": { + "noRecordsMessage": "Please create a customer group first." + } } }, "edit": { diff --git a/packages/admin/dashboard/src/routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx b/packages/admin/dashboard/src/routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx index bd4dad978ea1f..46e611ff87e8c 100644 --- a/packages/admin/dashboard/src/routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx +++ b/packages/admin/dashboard/src/routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx @@ -28,6 +28,7 @@ import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use import { useDataTable } from "../../../../../hooks/use-data-table.tsx" import { sdk } from "../../../../../lib/client/index.ts" import { queryClient } from "../../../../../lib/query-client.ts" +import { useBatchCustomerCustomerGroups } from "../../../../../hooks/api" type CustomerGroupSectionProps = { customer: HttpTypes.AdminCustomer @@ -57,6 +58,9 @@ export const CustomerGroupSection = ({ } ) + const { mutateAsync: batchCustomerCustomerGroups } = + useBatchCustomerCustomerGroups(customer.id) + const filters = useCustomerGroupTableFilters() const columns = useColumns(customer.id) @@ -94,20 +98,15 @@ export const CustomerGroupSection = ({ } try { - /** - * TODO: use this for now until add customer groups to customers batch is implemented - */ - const promises = customerGroupIds.map((id) => - sdk.admin.customerGroup.batchCustomers(id, { - remove: [customer.id], + await batchCustomerCustomerGroups({ remove: customerGroupIds }) + + toast.success( + t("customers.groups.removed.success", { + groups: customer_groups! + .filter((cg) => customerGroupIds.includes(cg.id)) + .map((cg) => cg?.name), }) ) - - await Promise.all(promises) - - await queryClient.invalidateQueries({ - queryKey: customerGroupsQueryKeys.lists(), - }) } catch (e) { toast.error(e.message) } diff --git a/packages/admin/dashboard/src/routes/customers/customers-add-customer-group/components/add-customers-form/add-customer-groups-form.tsx b/packages/admin/dashboard/src/routes/customers/customers-add-customer-group/components/add-customers-form/add-customer-groups-form.tsx index c3715f3b9e059..dc05964c9dfbb 100644 --- a/packages/admin/dashboard/src/routes/customers/customers-add-customer-group/components/add-customers-form/add-customer-groups-form.tsx +++ b/packages/admin/dashboard/src/routes/customers/customers-add-customer-group/components/add-customers-form/add-customer-groups-form.tsx @@ -17,16 +17,12 @@ import { } from "../../../../../components/modals" import { DataTable } from "../../../../../components/table/data-table" import { KeyboundForm } from "../../../../../components/utilities/keybound-form" -import { - customerGroupsQueryKeys, - useCustomerGroups, -} from "../../../../../hooks/api/customer-groups" +import { useCustomerGroups } from "../../../../../hooks/api/customer-groups" import { useCustomerGroupTableColumns } from "../../../../../hooks/table/columns/use-customer-group-table-columns" import { useCustomerGroupTableFilters } from "../../../../../hooks/table/filters/use-customer-group-table-filters" import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use-customer-group-table-query" import { useDataTable } from "../../../../../hooks/use-data-table" -import { sdk } from "../../../../../lib/client" -import { queryClient } from "../../../../../lib/query-client" +import { useBatchCustomerCustomerGroups } from "../../../../../hooks/api" type AddCustomerGroupsFormProps = { customerId: string @@ -45,6 +41,9 @@ export const AddCustomerGroupsForm = ({ const { handleSuccess } = useRouteModal() const [isPending, setIsPending] = useState(false) + const { mutateAsync: batchCustomerCustomerGroups } = + useBatchCustomerCustomerGroups(customerId) + const form = useForm>({ defaultValues: { customer_group_ids: [], @@ -117,16 +116,7 @@ export const AddCustomerGroupsForm = ({ const handleSubmit = form.handleSubmit(async (data) => { setIsPending(true) try { - /** - * TODO: use this for now until add customer groups to customers batch is implemented - */ - const promises = data.customer_group_ids.map((id) => - sdk.admin.customerGroup.batchCustomers(id, { - add: [customerId], - }) - ) - - await Promise.all(promises) + await batchCustomerCustomerGroups({ add: data.customer_group_ids }) toast.success( t("customers.groups.add.success", { @@ -137,10 +127,6 @@ export const AddCustomerGroupsForm = ({ }) ) - await queryClient.invalidateQueries({ - queryKey: customerGroupsQueryKeys.lists(), - }) - handleSuccess(`/customers/${customerId}`) } catch (e) { toast.error(e.message) diff --git a/packages/core/core-flows/src/customer-group/steps/index.ts b/packages/core/core-flows/src/customer-group/steps/index.ts index a1512feb765eb..60df54c74e2bd 100644 --- a/packages/core/core-flows/src/customer-group/steps/index.ts +++ b/packages/core/core-flows/src/customer-group/steps/index.ts @@ -2,3 +2,4 @@ export * from "./update-customer-groups" export * from "./delete-customer-groups" export * from "./create-customer-groups" export * from "./link-customers-customer-group" +export * from "./link-customer-groups-customer" diff --git a/packages/core/core-flows/src/customer-group/steps/link-customer-groups-customer.ts b/packages/core/core-flows/src/customer-group/steps/link-customer-groups-customer.ts new file mode 100644 index 0000000000000..486cfd34e589d --- /dev/null +++ b/packages/core/core-flows/src/customer-group/steps/link-customer-groups-customer.ts @@ -0,0 +1,56 @@ +import { + ICustomerModuleService, + LinkWorkflowInput, +} from "@medusajs/framework/types" +import { Modules, promiseAll } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +export const linkCustomerGroupsToCustomerStepId = + "link-customers-to-customer-group" +/** + * This step creates one or more links between a customer and customer groups records. + */ +export const linkCustomerGroupsToCustomerStep = createStep( + linkCustomerGroupsToCustomerStepId, + async (data: LinkWorkflowInput, { container }) => { + const service = container.resolve(Modules.CUSTOMER) + + const toAdd = (data.add ?? []).map((customerGroupId) => { + return { + customer_group_id: customerGroupId, + customer_id: data.id, + } + }) + + const toRemove = (data.remove ?? []).map((customerGroupId) => { + return { + customer_group_id: customerGroupId, + customer_id: data.id, + } + }) + + const promises: Promise[] = [] + if (toAdd.length) { + promises.push(service.addCustomerToGroup(toAdd)) + } + if (toRemove.length) { + promises.push(service.removeCustomerFromGroup(toRemove)) + } + await promiseAll(promises) + + return new StepResponse(void 0, { toAdd, toRemove }) + }, + async (prevData, { container }) => { + if (!prevData) { + return + } + const service = container.resolve(Modules.CUSTOMER) + + if (prevData.toAdd.length) { + await service.removeCustomerFromGroup(prevData.toAdd) + } + if (prevData.toRemove.length) { + await service.addCustomerToGroup(prevData.toRemove) + } + } +) diff --git a/packages/core/core-flows/src/customer-group/workflows/index.ts b/packages/core/core-flows/src/customer-group/workflows/index.ts index a1512feb765eb..60df54c74e2bd 100644 --- a/packages/core/core-flows/src/customer-group/workflows/index.ts +++ b/packages/core/core-flows/src/customer-group/workflows/index.ts @@ -2,3 +2,4 @@ export * from "./update-customer-groups" export * from "./delete-customer-groups" export * from "./create-customer-groups" export * from "./link-customers-customer-group" +export * from "./link-customer-groups-customer" diff --git a/packages/core/core-flows/src/customer-group/workflows/link-customer-groups-customer.ts b/packages/core/core-flows/src/customer-group/workflows/link-customer-groups-customer.ts new file mode 100644 index 0000000000000..a72b3e91ad106 --- /dev/null +++ b/packages/core/core-flows/src/customer-group/workflows/link-customer-groups-customer.ts @@ -0,0 +1,15 @@ +import { LinkWorkflowInput } from "@medusajs/framework/types" +import { WorkflowData, createWorkflow } from "@medusajs/framework/workflows-sdk" +import { linkCustomerGroupsToCustomerStep } from "../steps" + +export const linkCustomerGroupsToCustomerWorkflowId = + "link-customer-groups-to-customer" +/** + * This workflow creates one or more links between a customer and customer groups. + */ +export const linkCustomerGroupsToCustomerWorkflow = createWorkflow( + linkCustomerGroupsToCustomerWorkflowId, + (input: WorkflowData): WorkflowData => { + return linkCustomerGroupsToCustomerStep(input) + } +) diff --git a/packages/core/js-sdk/src/admin/customer.ts b/packages/core/js-sdk/src/admin/customer.ts index b40d9355a296c..4ce71c078f701 100644 --- a/packages/core/js-sdk/src/admin/customer.ts +++ b/packages/core/js-sdk/src/admin/customer.ts @@ -210,4 +210,38 @@ export class Customer { } ) } + + /** + * This method manages customer groups for a customer. + * It sends a request to the [Manage Customers](https://docs.medusajs.com/api/admin#customers_postcustomersidcustomergroups) + * API route. + * + * @param id - The customer's ID. + * @param body - The groups to add customer to or remove customer from. + * @param headers - Headers to pass in the request + * @returns The customers details. + * + * @example + * sdk.admin.customer.batchCustomerGroups("cus_123", { + * add: ["cusgroup_123"], + * remove: ["cusgroup_321"] + * }) + * .then(({ customer }) => { + * console.log(customer) + * }) + */ + async batchCustomerGroups( + id: string, + body: HttpTypes.AdminBatchLink, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/customers/${id}/customer-groups`, + { + method: "POST", + headers, + body, + } + ) + } } diff --git a/packages/medusa/src/api/admin/customers/[id]/customer-groups/route.ts b/packages/medusa/src/api/admin/customers/[id]/customer-groups/route.ts new file mode 100644 index 0000000000000..5b1d73186bb69 --- /dev/null +++ b/packages/medusa/src/api/admin/customers/[id]/customer-groups/route.ts @@ -0,0 +1,34 @@ +import { linkCustomerGroupsToCustomerWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" + +import { HttpTypes, LinkMethodRequest } from "@medusajs/framework/types" + +import { refetchCustomer } from "../../helpers" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + const { add, remove } = req.validatedBody + + const workflow = linkCustomerGroupsToCustomerWorkflow(req.scope) + await workflow.run({ + input: { + id, + add, + remove, + }, + }) + + const customer = await refetchCustomer( + id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ customer: customer }) +} diff --git a/packages/medusa/src/api/admin/customers/middlewares.ts b/packages/medusa/src/api/admin/customers/middlewares.ts index 5db65c2f16c3a..525f971ae8041 100644 --- a/packages/medusa/src/api/admin/customers/middlewares.ts +++ b/packages/medusa/src/api/admin/customers/middlewares.ts @@ -15,6 +15,7 @@ import { validateAndTransformBody, validateAndTransformQuery, } from "@medusajs/framework" +import { createLinkBody } from "../../utils/validators" export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -101,4 +102,15 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/customers/:id/customer-groups", + middlewares: [ + validateAndTransformBody(createLinkBody()), + validateAndTransformQuery( + AdminCustomerParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ]