feat(region): Region create, delete, update admin endpoints (#6332)

**What**
Add `POST /admin/regions`
Add `POST /admin/regions/:id`
Add `DELETE /admin/regions/:id`

All are added for v2 using API Routes and workflows.

In follow-up PRs, I will add support for passing countries in update and create.

Update: First follow-up PR is #6372
This commit is contained in:
Oli Juhl
2024-02-20 10:19:07 +00:00
committed by GitHub
parent 1d91b7429b
commit 691f68c3b8
22 changed files with 586 additions and 169 deletions
@@ -1,66 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IRegionModuleService } 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"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("GET /admin/regions/:id", () => {
let dbConnection
let appContainer
let shutdownServer
let regionModuleService: IRegionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
regionModuleService = appContainer.resolve(ModuleRegistrationName.REGION)
})
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 a region", async () => {
const [region] = await regionModuleService.create([
{
name: "Test",
currency_code: "usd",
},
])
const api = useApi() as any
const response = await api.get(`/admin/regions/${region.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.region).toEqual(
expect.objectContaining({
id: region.id,
name: "Test",
currency_code: "usd",
})
)
})
})
@@ -1,66 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IRegionModuleService } 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"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("GET /admin/regions", () => {
let dbConnection
let appContainer
let shutdownServer
let regionModuleService: IRegionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
regionModuleService = appContainer.resolve(ModuleRegistrationName.REGION)
})
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 regions and count", async () => {
await regionModuleService.create([
{
name: "Test",
currency_code: "usd",
},
])
const api = useApi() as any
const response = await api.get(`/admin/regions`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.regions).toEqual([
expect.objectContaining({
id: expect.any(String),
name: "Test",
currency_code: "usd",
}),
])
})
})
@@ -0,0 +1,219 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IRegionModuleService } 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"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("Regions - Admin", () => {
let dbConnection
let appContainer
let shutdownServer
let service: IRegionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
service = appContainer.resolve(ModuleRegistrationName.REGION)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await service.createDefaultCountriesAndCurrencies()
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should create, update, and delete a region", async () => {
const api = useApi() as any
const created = await api.post(
`/admin/regions`,
{
name: "Test Region",
currency_code: "usd",
},
adminHeaders
)
expect(created.status).toEqual(200)
expect(created.data.region).toEqual(
expect.objectContaining({
id: created.data.region.id,
name: "Test Region",
currency_code: "usd",
})
)
const updated = await api.post(
`/admin/regions`,
{
name: "United States",
currency_code: "usd",
},
adminHeaders
)
expect(updated.status).toEqual(200)
expect(updated.data.region).toEqual(
expect.objectContaining({
id: updated.data.region.id,
currency_code: "usd",
})
)
const deleted = await api.delete(
`/admin/regions/${updated.data.region.id}`,
adminHeaders
)
expect(deleted.status).toEqual(200)
expect(deleted.data).toEqual({
id: updated.data.region.id,
object: "region",
deleted: true,
})
const deletedRegion = await service.retrieve(updated.data.region.id, {
withDeleted: true,
})
// @ts-ignore
expect(deletedRegion.deleted_at).toBeTruthy()
})
it("should throw on missing required properties in create", async () => {
const api = useApi() as any
const err = await api
.post(`/admin/regions`, {}, adminHeaders)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
"name must be a string, currency_code must be a string"
)
})
it("should throw on unknown currency in create", async () => {
const api = useApi() as any
const error = await api
.post(
`/admin/regions`,
{
currency_code: "foo",
name: "Test Region",
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual(
"Currency with code: foo was not found"
)
})
it("should throw on unknown properties in create", async () => {
const api = useApi() as any
const error = await api
.post(
`/admin/regions`,
{
foo: "bar",
currency_code: "usd",
name: "Test Region",
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("property foo should not exist")
})
it("should throw on unknown properties in update", async () => {
const api = useApi() as any
const created = await service.create({
name: "Test Region",
currency_code: "usd",
})
const error = await api
.post(
`/admin/regions/${created.id}`,
{
foo: "bar",
currency_code: "usd",
name: "Test Region",
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("property foo should not exist")
})
it("should get all regions and count", async () => {
await service.create([
{
name: "Test",
currency_code: "usd",
},
])
const api = useApi() as any
const response = await api.get(`/admin/regions`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.regions).toEqual([
expect.objectContaining({
id: expect.any(String),
name: "Test",
currency_code: "usd",
}),
])
})
it("should get a region", async () => {
const [region] = await service.create([
{
name: "Test",
currency_code: "usd",
},
])
const api = useApi() as any
const response = await api.get(`/admin/regions/${region.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.region).toEqual(
expect.objectContaining({
id: region.id,
name: "Test",
currency_code: "usd",
})
)
})
})
+6 -4
View File
@@ -1,8 +1,10 @@
export * from "./customer"
export * from "./customer-group"
export * from "./definition"
export * from "./definitions"
export * as Handlers from "./handlers"
export * from "./promotion"
export * from "./customer"
export * from "./customer-group"
export * from "./user"
export * from "./invite"
export * from "./promotion"
export * from "./region"
export * from "./user"
+2
View File
@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"
@@ -0,0 +1,31 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CreateRegionDTO, IRegionModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const createRegionsStepId = "create-regions"
export const createRegionsStep = createStep(
createRegionsStepId,
async (data: CreateRegionDTO[], { container }) => {
const service = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
const created = await service.create(data)
return new StepResponse(
created,
created.map((region) => region.id)
)
},
async (createdIds, { container }) => {
if (!createdIds?.length) {
return
}
const service = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
await service.delete(createdIds)
}
)
@@ -0,0 +1,28 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IRegionModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const deleteRegionsStepId = "delete-regions"
export const deleteRegionsStep = createStep(
deleteRegionsStepId,
async (ids: string[], { container }) => {
const service = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
await service.softDelete(ids)
return new StepResponse(void 0, ids)
},
async (prevIds, { container }) => {
if (!prevIds?.length) {
return
}
const service = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
await service.restore(prevIds)
}
)
@@ -0,0 +1,3 @@
export * from "./create-regions"
export * from "./delete-regions"
export * from "./update-regions"
@@ -0,0 +1,54 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
FilterableRegionProps,
IRegionModuleService,
UpdatableRegionFields,
} from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type UpdateRegionsStepInput = {
selector: FilterableRegionProps
update: UpdatableRegionFields
}
export const updateRegionsStepId = "update-region"
export const updateRegionsStep = createStep(
updateRegionsStepId,
async (data: UpdateRegionsStepInput, { container }) => {
const service = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
data.update,
])
const prevData = await service.list(data.selector, {
select: selects,
relations,
})
const regions = await service.update(data.selector, data.update)
return new StepResponse(regions, prevData)
},
async (prevData, { container }) => {
if (!prevData?.length) {
return
}
const service = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
await service.update(
prevData.map((r) => ({
id: r.id,
name: r.name,
currency_code: r.currency_code,
metadata: r.metadata,
}))
)
}
)
@@ -0,0 +1,13 @@
import { CreateRegionDTO, RegionDTO } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createRegionsStep } from "../steps"
type WorkflowInput = { regionsData: CreateRegionDTO[] }
export const createRegionsWorkflowId = "create-regions"
export const createRegionsWorkflow = createWorkflow(
createRegionsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<RegionDTO[]> => {
return createRegionsStep(input.regionsData)
}
)
@@ -0,0 +1,12 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { deleteRegionsStep } from "../steps"
type WorkflowInput = { ids: string[] }
export const deleteRegionsWorkflowId = "delete-regions"
export const deleteRegionsWorkflow = createWorkflow(
deleteRegionsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<void> => {
return deleteRegionsStep(input.ids)
}
)
@@ -0,0 +1,3 @@
export * from "./create-regions"
export * from "./delete-regions"
export * from "./update-regions"
@@ -0,0 +1,22 @@
import {
FilterableRegionProps,
RegionDTO,
UpdatableRegionFields,
} from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updateRegionsStep } from "../steps"
type UpdateRegionsStepInput = {
selector: FilterableRegionProps
update: UpdatableRegionFields
}
type WorkflowInput = UpdateRegionsStepInput
export const updateRegionsWorkflowId = "update-regions"
export const updateRegionsWorkflow = createWorkflow(
updateRegionsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<RegionDTO[]> => {
return updateRegionsStep(input)
}
)
@@ -1,3 +1,8 @@
import {
deleteRegionsWorkflow,
updateRegionsWorkflow,
} from "@medusajs/core-flows"
import { UpdatableRegionFields } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { defaultAdminRegionFields } from "../query-config"
@@ -17,3 +22,38 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
res.status(200).json({ region })
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const { result, errors } = await updateRegionsWorkflow(req.scope).run({
input: {
selector: { id: req.params.id },
update: req.validatedBody as UpdatableRegionFields,
},
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ region: result[0] })
}
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
const id = req.params.id
const { errors } = await deleteRegionsWorkflow(req.scope).run({
input: { ids: [id] },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({
id,
object: "region",
deleted: true,
})
}
@@ -1,9 +1,11 @@
import { transformQuery } from "../../../api/middlewares"
import { transformBody, transformQuery } from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import * as QueryConfig from "./query-config"
import {
AdminGetRegionsParams,
AdminGetRegionsRegionParams,
AdminPostRegionsRegionReq,
AdminPostRegionsReq,
} from "./validators"
export const adminRegionRoutesMiddlewares: MiddlewareRoute[] = [
@@ -27,4 +29,14 @@ export const adminRegionRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/regions",
middlewares: [transformBody(AdminPostRegionsReq)],
},
{
method: ["POST"],
matcher: "/admin/regions/:id",
middlewares: [transformBody(AdminPostRegionsRegionReq)],
},
]
@@ -1,3 +1,5 @@
export const defaultAdminRegionRelations = ["countries", "currency"]
export const allowedAdminRegionRelations = ["countries", "currency"]
export const defaultAdminRegionFields = [
"id",
"name",
@@ -18,6 +20,9 @@ export const defaultAdminRegionFields = [
]
export const retrieveTransformQueryConfig = {
defaultFields: defaultAdminRegionFields,
defaultRelations: defaultAdminRegionRelations,
allowedRelations: allowedAdminRegionRelations,
isList: false,
}
@@ -1,3 +1,5 @@
import { createRegionsWorkflow } from "@medusajs/core-flows"
import { CreateRegionDTO } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
import { defaultAdminRegionFields } from "./query-config"
@@ -5,16 +7,42 @@ import { defaultAdminRegionFields } from "./query-config"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const remoteQuery = req.scope.resolve("remoteQuery")
const variables = { filters: req.filterableFields }
const queryObject = remoteQueryObjectFromString({
entryPoint: "region",
variables,
variables: {
filters: req.filterableFields,
order: req.listConfig.order,
skip: req.listConfig.skip,
take: req.listConfig.take,
},
fields: defaultAdminRegionFields,
})
// TODO: Add count, offset, limit
const regions = await remoteQuery(queryObject)
const { rows: regions, metadata } = await remoteQuery(queryObject)
res.json({ regions })
res.json({
regions,
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const input = [
{
...(req.validatedBody as CreateRegionDTO),
},
]
const { result, errors } = await createRegionsWorkflow(req.scope).run({
input: { regionsData: input },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ region: result[0] })
}
@@ -69,3 +69,21 @@ export class AdminGetRegionsParams extends extendedFindParamsMixin({
@Type(() => AdminGetRegionsParams)
$or?: AdminGetRegionsParams[]
}
export class AdminPostRegionsReq {
@IsString()
name: string
@IsString()
currency_code: string
}
export class AdminPostRegionsRegionReq {
@IsString()
@IsOptional()
name?: string
@IsString()
@IsOptional()
currency_code?: string
}
+1 -1
View File
@@ -10,11 +10,11 @@ import {
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { BaseEntity } from "../interfaces/models/base-entity"
import { generateEntityId } from "../utils/generate-entity-id"
import { Cart } from "./cart"
import { Currency } from "./currency"
import { Order } from "./order"
import { Swap } from "./swap"
import { generateEntityId } from "../utils/generate-entity-id"
@Index(["cart_id"], { where: "canceled_at IS NOT NULL" })
@Index("UniquePaymentActive", ["cart_id"], {
+57 -11
View File
@@ -2,6 +2,7 @@ import {
Context,
CreateRegionDTO,
DAL,
FilterableRegionProps,
InternalModuleDeclaration,
IRegionModuleService,
ModuleJoinerConfig,
@@ -9,11 +10,14 @@ import {
RegionCountryDTO,
RegionCurrencyDTO,
RegionDTO,
UpdatableRegionFields,
UpdateRegionDTO,
} from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
isObject,
isString,
MedusaContext,
MedusaError,
ModulesSdkUtils,
@@ -188,26 +192,68 @@ export default class RegionModuleService<
}
async update(
data: UpdateRegionDTO[],
selector: FilterableRegionProps,
data: UpdatableRegionFields,
sharedContext?: Context
): Promise<RegionDTO[]>
async update(
data: UpdateRegionDTO,
regionId: string,
data: UpdatableRegionFields,
sharedContext?: Context
): Promise<RegionDTO>
@InjectTransactionManager("baseRepository_")
async update(data: UpdateRegionDTO[]): Promise<RegionDTO[]>
@InjectManager("baseRepository_")
async update(
data: UpdateRegionDTO | UpdateRegionDTO[],
idOrSelectorOrData: string | FilterableRegionProps | UpdateRegionDTO[],
data?: UpdatableRegionFields,
@MedusaContext() sharedContext: Context = {}
): Promise<RegionDTO | RegionDTO[]> {
const result = await this.regionService_.update(data, sharedContext)
const result = await this.update_(idOrSelectorOrData, data, sharedContext)
return await this.baseRepository_.serialize<RegionDTO[]>(
Array.isArray(data) ? result : result[0],
{
populate: true,
}
)
const regions = await this.baseRepository_.serialize<
RegionDTO[] | RegionDTO
>(result)
return isString(idOrSelectorOrData) ? regions[0] : regions
}
@InjectTransactionManager("baseRepository_")
protected async update_(
idOrSelectorOrData: string | FilterableRegionProps | UpdateRegionDTO[],
data?: UpdatableRegionFields,
@MedusaContext() sharedContext: Context = {}
): Promise<Region[]> {
let result: Region[] = []
if (isString(idOrSelectorOrData)) {
result = await this.regionService_.update(
[{ id: idOrSelectorOrData, ...data }],
sharedContext
)
}
if (Array.isArray(idOrSelectorOrData)) {
result = await this.regionService_.update(
idOrSelectorOrData,
sharedContext
)
}
if (isObject(idOrSelectorOrData)) {
let toUpdate: Partial<UpdateRegionDTO>[] = []
const regions = await this.regionService_.list(
{ ...idOrSelectorOrData },
{},
sharedContext
)
regions.forEach((region) => {
toUpdate.push({ id: region.id, ...data })
})
result = await this.regionService_.update(toUpdate, sharedContext)
}
return result
}
@InjectManager("baseRepository_")
+9 -11
View File
@@ -1,24 +1,22 @@
import { RegionCurrencyDTO } from "./common"
export interface CreateRegionDTO {
name: string
currency_code: string
currency?: RegionCurrencyDTO
countries?: string[]
tax_code?: string
tax_rate?: number
tax_provider_id?: string
metadata?: Record<string, unknown>
}
export interface UpdateRegionDTO {
id: string
currency_code?: string
currency?: RegionCurrencyDTO
countries: string[]
countries?: string[]
name?: string
tax_code?: string
tax_rate?: number
tax_provider_id?: string
metadata?: Record<string, unknown>
}
export interface UpdatableRegionFields {
currency_code?: string
name?: string
metadata?: Record<string, unknown>
}
export interface AddCountryToRegionDTO {
+16 -3
View File
@@ -10,14 +10,27 @@ import {
RegionCurrencyDTO,
RegionDTO,
} from "./common"
import { CreateRegionDTO, UpdateRegionDTO } from "./mutations"
import {
CreateRegionDTO,
UpdatableRegionFields,
UpdateRegionDTO,
} from "./mutations"
export interface IRegionModuleService extends IModuleService {
create(data: CreateRegionDTO[], sharedContext?: Context): Promise<RegionDTO[]>
create(data: CreateRegionDTO, sharedContext?: Context): Promise<RegionDTO>
update(data: UpdateRegionDTO[], sharedContext?: Context): Promise<RegionDTO[]>
update(data: UpdateRegionDTO, sharedContext?: Context): Promise<RegionDTO>
update(data: UpdateRegionDTO[]): Promise<RegionDTO[]>
update(
selector: FilterableRegionProps,
data: UpdatableRegionFields,
sharedContext?: Context
): Promise<RegionDTO[]>
update(
regionId: string,
data: UpdatableRegionFields,
sharedContext?: Context
): Promise<RegionDTO>
delete(ids: string[], sharedContext?: Context): Promise<void>
delete(id: string, sharedContext?: Context): Promise<void>