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
2024-01-30 12:43:30 +01:00
committed by GitHub
parent 328eb85a8b
commit 18ff739a94
21 changed files with 503 additions and 25 deletions
@@ -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 * as Handlers from "./handlers"
export * from "./promotion"
export * from "./customer"
@@ -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;'
)
@@ -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<CustomerTypes.CreateCustomerDTO>,
data: CustomerUpdatableFields,
sharedContext?: Context
): Promise<CustomerTypes.CustomerDTO>
update(
customerIds: string[],
data: Partial<CustomerTypes.CreateCustomerDTO>,
data: CustomerUpdatableFields,
sharedContext?: Context
): Promise<CustomerTypes.CustomerDTO[]>
update(
selector: CustomerTypes.FilterableCustomerProps,
data: Partial<CustomerTypes.CreateCustomerDTO>,
data: CustomerUpdatableFields,
sharedContext?: Context
): Promise<CustomerTypes.CustomerDTO[]>
@InjectTransactionManager("baseRepository_")
async update(
idsOrSelector: string | string[] | CustomerTypes.FilterableCustomerProps,
data: Partial<CustomerTypes.CreateCustomerDTO>,
data: CustomerUpdatableFields,
@MedusaContext() sharedContext: Context = {}
) {
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 {
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",
@@ -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] })
}
@@ -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
}
+15 -6
View File
@@ -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<string, unknown>
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 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 {
+4 -3
View File
@@ -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<CreateCustomerDTO>,
data: CustomerUpdatableFields,
sharedContext?: Context
): Promise<CustomerDTO>
update(
customerIds: string[],
data: Partial<CreateCustomerDTO>,
data: CustomerUpdatableFields,
sharedContext?: Context
): Promise<CustomerDTO[]>
update(
selector: FilterableCustomerProps,
data: Partial<CreateCustomerDTO>,
data: CustomerUpdatableFields,
sharedContext?: Context
): Promise<CustomerDTO[]>