diff --git a/integration-tests/plugins/__tests__/customer/admin/create-customer.ts b/integration-tests/plugins/__tests__/customer/admin/create-customer.ts new file mode 100644 index 0000000000000..1a11e54234933 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/create-customer.ts @@ -0,0 +1,67 @@ +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/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 create a customer", async () => { + const api = useApi() as any + const response = await api.post( + `/admin/customers`, + { + first_name: "John", + last_name: "Doe", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.customer).toEqual( + expect.objectContaining({ + id: expect.any(String), + first_name: "John", + last_name: "Doe", + created_by: "admin_user", + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts b/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts new file mode 100644 index 0000000000000..0d1c032c14539 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts @@ -0,0 +1,65 @@ +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/customers/:id", () => { + 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 delete a customer", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + + const api = useApi() as any + const response = await api.delete( + `/admin/customers/${customer.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const deletedCustomer = await customerModuleService.retrieve(customer.id, { + withDeleted: true, + }) + expect(deletedCustomer.deleted_at).toBeTruthy() + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/admin/update-customer.ts b/integration-tests/plugins/__tests__/customer/admin/update-customer.ts new file mode 100644 index 0000000000000..92b8c9da32114 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/update-customer.ts @@ -0,0 +1,70 @@ +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/customers/:id", () => { + 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 update a customer", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + + const api = useApi() as any + const response = await api.post( + `/admin/customers/${customer.id}`, + { + first_name: "Jane", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.customer).toEqual( + expect.objectContaining({ + id: expect.any(String), + first_name: "Jane", + last_name: "Doe", + }) + ) + }) +}) diff --git a/packages/core-flows/src/customer/index.ts b/packages/core-flows/src/customer/index.ts new file mode 100644 index 0000000000000..68de82c9f92da --- /dev/null +++ b/packages/core-flows/src/customer/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/customer/steps/create-customers.ts b/packages/core-flows/src/customer/steps/create-customers.ts new file mode 100644 index 0000000000000..3310e1e8738ce --- /dev/null +++ b/packages/core-flows/src/customer/steps/create-customers.ts @@ -0,0 +1,31 @@ +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { CreateCustomerDTO, ICustomerModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const createCustomersStepId = "create-customers" +export const createCustomersStep = createStep( + createCustomersStepId, + async (data: CreateCustomerDTO[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const createdCustomers = await service.create(data) + + return new StepResponse( + createdCustomers, + createdCustomers.map((createdCustomers) => createdCustomers.id) + ) + }, + async (createdCustomerIds, { container }) => { + if (!createdCustomerIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.delete(createdCustomerIds) + } +) diff --git a/packages/core-flows/src/customer/steps/delete-customers.ts b/packages/core-flows/src/customer/steps/delete-customers.ts new file mode 100644 index 0000000000000..35b6947c73d1e --- /dev/null +++ b/packages/core-flows/src/customer/steps/delete-customers.ts @@ -0,0 +1,30 @@ +import { ICustomerModuleService } from "@medusajs/types" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +type DeleteCustomerStepInput = string[] + +export const deleteCustomerStepId = "delete-customer" +export const deleteCustomerStep = createStep( + deleteCustomerStepId, + async (ids: DeleteCustomerStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.softDelete(ids) + + return new StepResponse(void 0, ids) + }, + async (prevCustomerIds, { container }) => { + if (!prevCustomerIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.restore(prevCustomerIds) + } +) diff --git a/packages/core-flows/src/customer/steps/index.ts b/packages/core-flows/src/customer/steps/index.ts new file mode 100644 index 0000000000000..6ea82b9cd6882 --- /dev/null +++ b/packages/core-flows/src/customer/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./create-customers" +export * from "./update-customers" +export * from "./delete-customers" diff --git a/packages/core-flows/src/customer/steps/update-customers.ts b/packages/core-flows/src/customer/steps/update-customers.ts new file mode 100644 index 0000000000000..c0e7c90f3b4a9 --- /dev/null +++ b/packages/core-flows/src/customer/steps/update-customers.ts @@ -0,0 +1,59 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + FilterableCustomerProps, + ICustomerModuleService, + CustomerUpdatableFields, +} from "@medusajs/types" +import { + getSelectsAndRelationsFromObjectArray, + promiseAll, +} from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +type UpdateCustomersStepInput = { + selector: FilterableCustomerProps + update: CustomerUpdatableFields +} + +export const updateCustomersStepId = "update-customer" +export const updateCustomersStep = createStep( + updateCustomersStepId, + async (data: UpdateCustomersStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + const prevCustomers = await service.list(data.selector, { + select: selects, + relations, + }) + + const customers = await service.update(data.selector, data.update) + + return new StepResponse(customers, prevCustomers) + }, + async (prevCustomers, { container }) => { + if (!prevCustomers?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await promiseAll( + prevCustomers.map((c) => + service.update(c.id, { + first_name: c.first_name, + last_name: c.last_name, + email: c.email, + phone: c.phone, + metadata: c.metadata, + }) + ) + ) + } +) diff --git a/packages/core-flows/src/customer/workflows/create-customers.ts b/packages/core-flows/src/customer/workflows/create-customers.ts new file mode 100644 index 0000000000000..23e19d33d1d40 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/create-customers.ts @@ -0,0 +1,13 @@ +import { CustomerDTO, CreateCustomerDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createCustomersStep } from "../steps" + +type WorkflowInput = { customersData: CreateCustomerDTO[] } + +export const createCustomersWorkflowId = "create-customers" +export const createCustomersWorkflow = createWorkflow( + createCustomersWorkflowId, + (input: WorkflowData): WorkflowData => { + return createCustomersStep(input.customersData) + } +) diff --git a/packages/core-flows/src/customer/workflows/delete-customers.ts b/packages/core-flows/src/customer/workflows/delete-customers.ts new file mode 100644 index 0000000000000..7603d4daec2bf --- /dev/null +++ b/packages/core-flows/src/customer/workflows/delete-customers.ts @@ -0,0 +1,12 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteCustomerStep } from "../steps" + +type WorkflowInput = { ids: string[] } + +export const deleteCustomersWorkflowId = "delete-customers" +export const deleteCustomersWorkflow = createWorkflow( + deleteCustomersWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteCustomerStep(input.ids) + } +) diff --git a/packages/core-flows/src/customer/workflows/index.ts b/packages/core-flows/src/customer/workflows/index.ts new file mode 100644 index 0000000000000..6ea82b9cd6882 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/index.ts @@ -0,0 +1,3 @@ +export * from "./create-customers" +export * from "./update-customers" +export * from "./delete-customers" diff --git a/packages/core-flows/src/customer/workflows/update-customers.ts b/packages/core-flows/src/customer/workflows/update-customers.ts new file mode 100644 index 0000000000000..478533f305cd4 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/update-customers.ts @@ -0,0 +1,22 @@ +import { + CustomerDTO, + CustomerUpdatableFields, + FilterableCustomerProps, +} from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateCustomersStep } from "../steps" + +type UpdateCustomersStepInput = { + selector: FilterableCustomerProps + update: CustomerUpdatableFields +} + +type WorkflowInput = UpdateCustomersStepInput + +export const updateCustomersWorkflowId = "update-customers" +export const updateCustomersWorkflow = createWorkflow( + updateCustomersWorkflowId, + (input: WorkflowData): WorkflowData => { + return updateCustomersStep(input) + } +) diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index b6819d03ff15c..0d26e847b65b8 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -2,3 +2,4 @@ export * from "./definition" export * from "./definitions" export * as Handlers from "./handlers" export * from "./promotion" +export * from "./customer" diff --git a/packages/customer/src/migrations/Migration20240124154000.ts b/packages/customer/src/migrations/Migration20240124154000.ts index 443d06f80db9a..c44f78d7f29a0 100644 --- a/packages/customer/src/migrations/Migration20240124154000.ts +++ b/packages/customer/src/migrations/Migration20240124154000.ts @@ -6,6 +6,7 @@ export class Migration20240124154000 extends Migration { this.addSql( 'create table if not exists "customer" ("id" text not null, "company_name" text null, "first_name" text null, "last_name" text null, "email" text null, "phone" text null, "has_account" boolean not null default false, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "created_by" text null, constraint "customer_pkey" primary key ("id"));' ) + this.addSql('alter table "customer" alter column "email" drop not null;') this.addSql( 'alter table "customer" add column if not exists "company_name" text null;' ) diff --git a/packages/customer/src/services/customer-module.ts b/packages/customer/src/services/customer-module.ts index 40c682e08a640..a2352f1d67565 100644 --- a/packages/customer/src/services/customer-module.ts +++ b/packages/customer/src/services/customer-module.ts @@ -8,6 +8,7 @@ import { CustomerTypes, SoftDeleteReturn, RestoreReturn, + CustomerUpdatableFields, } from "@medusajs/types" import { @@ -110,24 +111,24 @@ export default class CustomerModuleService implements ICustomerModuleService { update( customerId: string, - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise update( customerIds: string[], - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise update( selector: CustomerTypes.FilterableCustomerProps, - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise @InjectTransactionManager("baseRepository_") async update( idsOrSelector: string | string[] | CustomerTypes.FilterableCustomerProps, - data: Partial, + data: CustomerUpdatableFields, @MedusaContext() sharedContext: Context = {} ) { let updateData: CustomerTypes.UpdateCustomerDTO[] = [] diff --git a/packages/medusa/src/api-v2/admin/customers/[id]/route.ts b/packages/medusa/src/api-v2/admin/customers/[id]/route.ts new file mode 100644 index 0000000000000..fbf8c8cc6e8a5 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customers/[id]/route.ts @@ -0,0 +1,60 @@ +import { + updateCustomersWorkflow, + deleteCustomersWorkflow, +} from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CustomerUpdatableFields, + ICustomerModuleService, +} from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const customerModuleService = req.scope.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const customer = await customerModuleService.retrieve(req.params.id, { + select: req.retrieveConfig.select, + relations: req.retrieveConfig.relations, + }) + + res.status(200).json({ customer }) +} + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const updateCustomers = updateCustomersWorkflow(req.scope) + const { result, errors } = await updateCustomers.run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody as CustomerUpdatableFields, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ customer: result[0] }) +} + +export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { + const id = req.params.id + const deleteCustomers = deleteCustomersWorkflow(req.scope) + + const { errors } = await deleteCustomers.run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "customer", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/customers/middlewares.ts b/packages/medusa/src/api-v2/admin/customers/middlewares.ts index 962b1a4529ccf..3d72dcc9873ef 100644 --- a/packages/medusa/src/api-v2/admin/customers/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/customers/middlewares.ts @@ -1,23 +1,14 @@ -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 { AdminGetCustomersParams, AdminGetCustomersCustomerParams, + AdminPostCustomersReq, AdminPostCustomersCustomerReq, } from "./validators" export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ - { - matcher: "/admin/customers*", - middlewares: [isFeatureFlagEnabled(MedusaV2Flag.key)], - }, { method: ["GET"], matcher: "/admin/customers", @@ -28,6 +19,11 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/customers", + middlewares: [transformBody(AdminPostCustomersReq)], + }, { method: ["GET"], matcher: "/admin/customers/:id", diff --git a/packages/medusa/src/api-v2/admin/customers/route.ts b/packages/medusa/src/api-v2/admin/customers/route.ts index 8be7d305362b7..302d46f430e82 100644 --- a/packages/medusa/src/api-v2/admin/customers/route.ts +++ b/packages/medusa/src/api-v2/admin/customers/route.ts @@ -1,5 +1,6 @@ +import { createCustomersWorkflow } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { ICustomerModuleService } from "@medusajs/types" +import { CreateCustomerDTO, ICustomerModuleService } from "@medusajs/types" import { MedusaRequest, MedusaResponse } from "../../../types/routing" export const GET = async (req: MedusaRequest, res: MedusaResponse) => { @@ -39,3 +40,24 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { limit, }) } + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const createCustomers = createCustomersWorkflow(req.scope) + const customersData = [ + { + ...(req.validatedBody as CreateCustomerDTO), + created_by: req.user!.id, + }, + ] + + const { result, errors } = await createCustomers.run({ + input: { customersData }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ customer: result[0] }) +} diff --git a/packages/medusa/src/api-v2/admin/customers/validators.ts b/packages/medusa/src/api-v2/admin/customers/validators.ts index 4f1fda5ca0816..40703d4961ff7 100644 --- a/packages/medusa/src/api-v2/admin/customers/validators.ts +++ b/packages/medusa/src/api-v2/admin/customers/validators.ts @@ -121,6 +121,11 @@ export class AdminPostCustomersReq { @IsString() @IsOptional() email?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + phone?: string } export class AdminPostCustomersCustomerReq { @@ -143,4 +148,9 @@ export class AdminPostCustomersCustomerReq { @IsString() @IsOptional() email?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + phone?: string } diff --git a/packages/types/src/customer/mutations.ts b/packages/types/src/customer/mutations.ts index a2275be9a20c9..1e1d42334e300 100644 --- a/packages/types/src/customer/mutations.ts +++ b/packages/types/src/customer/mutations.ts @@ -48,12 +48,21 @@ export interface CreateCustomerDTO { export interface UpdateCustomerDTO { id: string - company_name?: string - first_name?: string - last_name?: string - email?: string - phone?: string - metadata?: Record + company_name?: string | null + first_name?: string | null + last_name?: string | null + email?: string | null + phone?: string | null + metadata?: Record | null +} + +export interface CustomerUpdatableFields { + company_name?: string | null + first_name?: string | null + last_name?: string | null + email?: string | null + phone?: string | null + metadata?: Record | null } export interface CreateCustomerGroupDTO { diff --git a/packages/types/src/customer/service.ts b/packages/types/src/customer/service.ts index bd45ec1dcd6f5..56383da8a42f8 100644 --- a/packages/types/src/customer/service.ts +++ b/packages/types/src/customer/service.ts @@ -17,6 +17,7 @@ import { CreateCustomerAddressDTO, CreateCustomerDTO, CreateCustomerGroupDTO, + CustomerUpdatableFields, UpdateCustomerAddressDTO, } from "./mutations" @@ -35,17 +36,17 @@ export interface ICustomerModuleService extends IModuleService { update( customerId: string, - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise update( customerIds: string[], - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise update( selector: FilterableCustomerProps, - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise