feat(customer): admin CRUD endpoints (#6232)

**What**

- GET /customers/:id
- POST /customers/:id
- DELETE /customers/:id
- POST /customers

Including workflows for each.
This commit is contained in:
Sebastian Rindom authored and GitHub committed 2024-01-30 11:43:30 +00:00
1 parent 328eb85a8b
commit 18ff739a94
21 files changed
+503 -25

No files matched your search

@@ -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",
})
)
})
})
@@ -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()
})
})
@@ -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",
})
)
})
})
@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"
@@ -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<ICustomerModuleService>(
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<ICustomerModuleService>(
ModuleRegistrationName.CUSTOMER
)
await service.delete(createdCustomerIds)
}
)
@@ -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<ICustomerModuleService>(
ModuleRegistrationName.CUSTOMER
)
await service.softDelete(ids)
return new StepResponse(void 0, ids)
},
async (prevCustomerIds, { container }) => {
if (!prevCustomerIds?.length) {
return
}
const service = container.resolve<ICustomerModuleService>(
ModuleRegistrationName.CUSTOMER
)
await service.restore(prevCustomerIds)
}
)
@@ -0,0 +1,3 @@
export * from "./create-customers"
export * from "./update-customers"
export * from "./delete-customers"
@@ -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<ICustomerModuleService>(
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<ICustomerModuleService>(
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,
})
)
)
}
)
@@ -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<WorkflowInput>): WorkflowData<CustomerDTO[]> => {
return createCustomersStep(input.customersData)
}
)
@@ -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<WorkflowInput>): WorkflowData<void> => {
return deleteCustomerStep(input.ids)
}
)
@@ -0,0 +1,3 @@
export * from "./create-customers"
export * from "./update-customers"
export * from "./delete-customers"
@@ -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<WorkflowInput>): WorkflowData<CustomerDTO[]> => {
return updateCustomersStep(input)
}
)
+1
View File
@@ -2,3 +2,4 @@ export * from "./definition"
export * from "./definitions" export * from "./definitions"
export * as Handlers from "./handlers" export * as Handlers from "./handlers"
export * from "./promotion" export * from "./promotion"
export * from "./customer"
@@ -6,6 +6,7 @@ export class Migration20240124154000 extends Migration {
this.addSql( 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"));' '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( this.addSql(
'alter table "customer" add column if not exists "company_name" text null;' 'alter table "customer" add column if not exists "company_name" text null;'
) )
@@ -8,6 +8,7 @@ import {
CustomerTypes, CustomerTypes,
SoftDeleteReturn, SoftDeleteReturn,
RestoreReturn, RestoreReturn,
CustomerUpdatableFields,
} from "@medusajs/types" } from "@medusajs/types"
import { import {
@@ -110,24 +111,24 @@ export default class CustomerModuleService implements ICustomerModuleService {
update( update(
customerId: string, customerId: string,
data: Partial<CustomerTypes.CreateCustomerDTO>, data: CustomerUpdatableFields,
sharedContext?: Context sharedContext?: Context
): Promise<CustomerTypes.CustomerDTO> ): Promise<CustomerTypes.CustomerDTO>
update( update(
customerIds: string[], customerIds: string[],
data: Partial<CustomerTypes.CreateCustomerDTO>, data: CustomerUpdatableFields,
sharedContext?: Context sharedContext?: Context
): Promise<CustomerTypes.CustomerDTO[]> ): Promise<CustomerTypes.CustomerDTO[]>
update( update(
selector: CustomerTypes.FilterableCustomerProps, selector: CustomerTypes.FilterableCustomerProps,
data: Partial<CustomerTypes.CreateCustomerDTO>, data: CustomerUpdatableFields,
sharedContext?: Context sharedContext?: Context
): Promise<CustomerTypes.CustomerDTO[]> ): Promise<CustomerTypes.CustomerDTO[]>
@InjectTransactionManager("baseRepository_") @InjectTransactionManager("baseRepository_")
async update( async update(
idsOrSelector: string | string[] | CustomerTypes.FilterableCustomerProps, idsOrSelector: string | string[] | CustomerTypes.FilterableCustomerProps,
data: Partial<CustomerTypes.CreateCustomerDTO>, data: CustomerUpdatableFields,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
) { ) {
let updateData: CustomerTypes.UpdateCustomerDTO[] = [] let updateData: CustomerTypes.UpdateCustomerDTO[] = []
@@ -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<ICustomerModuleService>(
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,
})
}
@@ -1,23 +1,14 @@
import { MedusaV2Flag } from "@medusajs/utils" import { transformBody, transformQuery } from "../../../api/middlewares"
import {
isFeatureFlagEnabled,
transformBody,
transformQuery,
} from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import * as QueryConfig from "./query-config" import * as QueryConfig from "./query-config"
import { import {
AdminGetCustomersParams, AdminGetCustomersParams,
AdminGetCustomersCustomerParams, AdminGetCustomersCustomerParams,
AdminPostCustomersReq,
AdminPostCustomersCustomerReq, AdminPostCustomersCustomerReq,
} from "./validators" } from "./validators"
export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/admin/customers*",
middlewares: [isFeatureFlagEnabled(MedusaV2Flag.key)],
},
{ {
method: ["GET"], method: ["GET"],
matcher: "/admin/customers", matcher: "/admin/customers",
@@ -28,6 +19,11 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [
), ),
], ],
}, },
{
method: ["POST"],
matcher: "/admin/customers",
middlewares: [transformBody(AdminPostCustomersReq)],
},
{ {
method: ["GET"], method: ["GET"],
matcher: "/admin/customers/:id", matcher: "/admin/customers/:id",
@@ -1,5 +1,6 @@
import { createCustomersWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICustomerModuleService } from "@medusajs/types" import { CreateCustomerDTO, ICustomerModuleService } from "@medusajs/types"
import { MedusaRequest, MedusaResponse } from "../../../types/routing" import { MedusaRequest, MedusaResponse } from "../../../types/routing"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => { export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
@@ -39,3 +40,24 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
limit, 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] })
}
@@ -121,6 +121,11 @@ export class AdminPostCustomersReq {
@IsString() @IsString()
@IsOptional() @IsOptional()
email?: string email?: string
@IsNotEmpty()
@IsString()
@IsOptional()
phone?: string
} }
export class AdminPostCustomersCustomerReq { export class AdminPostCustomersCustomerReq {
@@ -143,4 +148,9 @@ export class AdminPostCustomersCustomerReq {
@IsString() @IsString()
@IsOptional() @IsOptional()
email?: string email?: string
@IsNotEmpty()
@IsString()
@IsOptional()
phone?: string
} }
+15 -6
View File
@@ -48,12 +48,21 @@ export interface CreateCustomerDTO {
export interface UpdateCustomerDTO { export interface UpdateCustomerDTO {
id: string id: string
company_name?: string company_name?: string | null
first_name?: string first_name?: string | null
last_name?: string last_name?: string | null
email?: string email?: string | null
phone?: string phone?: string | null
metadata?: Record<string, unknown> metadata?: Record<string, unknown> | null
}
export interface CustomerUpdatableFields {
company_name?: string | null
first_name?: string | null
last_name?: string | null
email?: string | null
phone?: string | null
metadata?: Record<string, unknown> | null
} }
export interface CreateCustomerGroupDTO { export interface CreateCustomerGroupDTO {
+4 -3
View File
@@ -17,6 +17,7 @@ import {
CreateCustomerAddressDTO, CreateCustomerAddressDTO,
CreateCustomerDTO, CreateCustomerDTO,
CreateCustomerGroupDTO, CreateCustomerGroupDTO,
CustomerUpdatableFields,
UpdateCustomerAddressDTO, UpdateCustomerAddressDTO,
} from "./mutations" } from "./mutations"
@@ -35,17 +36,17 @@ export interface ICustomerModuleService extends IModuleService {
update( update(
customerId: string, customerId: string,
data: Partial<CreateCustomerDTO>, data: CustomerUpdatableFields,
sharedContext?: Context sharedContext?: Context
): Promise<CustomerDTO> ): Promise<CustomerDTO>
update( update(
customerIds: string[], customerIds: string[],
data: Partial<CreateCustomerDTO>, data: CustomerUpdatableFields,
sharedContext?: Context sharedContext?: Context
): Promise<CustomerDTO[]> ): Promise<CustomerDTO[]>
update( update(
selector: FilterableCustomerProps, selector: FilterableCustomerProps,
data: Partial<CreateCustomerDTO>, data: CustomerUpdatableFields,
sharedContext?: Context sharedContext?: Context
): Promise<CustomerDTO[]> ): Promise<CustomerDTO[]>