feat: Sales Channels API routes + workflows (#6722)

**What**
- Admin sales channels API routes
- Workflows for creating, updating, and deleting sales channels
- Align `ISalesChannelModuleService` interface with update + upsert conventions
- Integrate v2 tests into v1 sales channels integration test suite
- Add metadata to sales channel entity

Sales channel <> Product management will come in a follow-up PR.

On the integration tests, creating, updating, and deleting are passing with the V2 flag enabled. You'll have to take my word for it (or run them yourself), as they are not included in the CI.
This commit is contained in:
Oli Juhl authored and GitHub committed 2024-03-18 20:57:29 +00:00
1 parent 4b06c17dc0
commit 0219a8677b
28 files changed
+803 -309

No files matched your search

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`sales channels DELETE /admin/sales-channels/:id should delete the requested sales channel 1`] = `
exports[` DELETE /admin/sales-channels/:id should delete the requested sales channel 1`] = `
Object {
"deleted": true,
"id": Any<String>,
@@ -8,7 +8,7 @@ Object {
}
`;
exports[`sales channels POST /admin/sales-channels successfully creates a disabled sales channel 1`] = `
exports[` POST /admin/sales-channels successfully creates a disabled sales channel 1`] = `
Object {
"sales_channel": ObjectContaining {
"is_disabled": true,
@@ -17,7 +17,7 @@ Object {
}
`;
exports[`sales channels POST /admin/sales-channels successfully creates a sales channel 1`] = `
exports[` POST /admin/sales-channels successfully creates a sales channel 1`] = `
Object {
"sales_channel": ObjectContaining {
"description": "sales channel description",
@@ -1,21 +1,7 @@
const path = require("path")
const { SalesChannel, Product } = require("@medusajs/medusa")
const { useApi } = require("../../../environment-helpers/use-api")
const { useDb } = require("../../../environment-helpers/use-db")
const adminSeeder = require("../../../helpers/admin-seeder")
const {
simpleSalesChannelFactory,
simpleProductFactory,
} = require("../../../factories")
const { simpleOrderFactory } = require("../../../factories")
const orderSeeder = require("../../../helpers/order-seeder")
const productSeeder = require("../../../helpers/product-seeder")
const startServerWithEnvironment =
require("../../../environment-helpers/start-server-with-environment").default
const { ModuleRegistrationName } = require("@medusajs/modules-sdk")
const { medusaIntegrationTestRunner } = require("medusa-test-utils")
const { createAdminUser } = require("../../../helpers/create-admin-user")
const { breaking } = require("../../../helpers/breaking")
const adminReqConfig = {
headers: {
@@ -23,47 +9,61 @@ const adminReqConfig = {
},
}
let { simpleSalesChannelFactory, simpleProductFactory, simpleOrderFactory } = {}
let { SalesChannel, Product } = {}
let orderSeeder
let productSeeder
jest.setTimeout(60000)
describe("sales channels", () => {
let medusaProcess
let dbConnection
medusaIntegrationTestRunner({
// env: { MEDUSA_FF_MEDUSA_V2: true },
testSuite: ({ dbConnection, getContainer, api }) => {
let container
let salesChannelService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_SALES_CHANNELS: true },
})
dbConnection = connection
medusaProcess = process
beforeAll(() => {
;({
simpleProductFactory,
simpleSalesChannelFactory,
simpleOrderFactory,
} = require("../../../factories"))
;({ SalesChannel, Product } = require("@medusajs/medusa"))
orderSeeder = require("../../../helpers/order-seeder")
productSeeder = require("../../../helpers/product-seeder")
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
beforeEach(async () => {
container = getContainer()
await createAdminUser(dbConnection, adminReqConfig, container)
medusaProcess.kill()
salesChannelService = container.resolve(
ModuleRegistrationName.SALES_CHANNEL
)
})
describe("GET /admin/sales-channels/:id", () => {
let salesChannel
beforeEach(async () => {
await adminSeeder(dbConnection)
salesChannel = await simpleSalesChannelFactory(dbConnection, {
salesChannel = await breaking(
async () => {
return await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
},
async () => {
return await salesChannelService.create({
name: "test name",
description: "test description",
})
afterEach(async () => {
const db = useDb()
await db.teardown()
}
)
})
it("should retrieve the requested sales channel", async () => {
const api = useApi()
const response = await api.get(
`/admin/sales-channels/${salesChannel.id}`,
adminReqConfig
@@ -88,29 +88,48 @@ describe("sales channels", () => {
let salesChannel2
beforeEach(async () => {
await adminSeeder(dbConnection)
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
salesChannel1 = await breaking(
async () => {
return await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
},
async () => {
return await salesChannelService.create({
name: "test name",
description: "test description",
})
}
)
salesChannel2 = await breaking(
async () => {
return await simpleSalesChannelFactory(dbConnection, {
name: "test name 2",
description: "test description 2",
})
},
async () => {
return await salesChannelService.create({
name: "test name 2",
description: "test description 2",
})
afterEach(async () => {
const db = useDb()
await db.teardown()
}
)
})
it("should list the sales channel", async () => {
const api = useApi()
const response = await api.get(`/admin/sales-channels/`, adminReqConfig)
const response = await api.get(`/admin/sales-channels`, adminReqConfig)
expect(response.status).toEqual(200)
expect(response.data.sales_channels).toBeTruthy()
expect(response.data.sales_channels.length).toBe(2)
expect(response.data.sales_channels.length).toBe(
breaking(
() => 3, // Two created + the default one
() => 2 // This will not be breaking once the default sales channel is created in the new API
)
)
expect(response.data).toEqual(
expect.objectContaining({
sales_channels: expect.arrayContaining([
@@ -127,13 +146,16 @@ describe("sales channels", () => {
)
})
it("should list the sales channel using free text search", async () => {
const api = useApi()
const response = await api.get(
`/admin/sales-channels/?q=2`,
adminReqConfig
it("should list the sales channel using filters", async () => {
const response = await breaking(
async () => {
return await api.get(`/admin/sales-channels?q=2`, adminReqConfig)
},
() => undefined
)
breaking(
() => {
expect(response.status).toEqual(200)
expect(response.data.sales_channels).toBeTruthy()
expect(response.data.sales_channels.length).toBe(1)
@@ -153,12 +175,17 @@ describe("sales channels", () => {
}),
]),
})
},
() => {
// TODO: Free text search is not supported in the new sales channel API (yet)
expect(response).toBeUndefined()
}
)
})
it("should list the sales channel using properties filters", async () => {
const api = useApi()
const response = await api.get(
`/admin/sales-channels/?name=test+name`,
`/admin/sales-channels?name=test+name`,
adminReqConfig
)
@@ -188,21 +215,23 @@ describe("sales channels", () => {
let sc
beforeEach(async () => {
await adminSeeder(dbConnection)
sc = await simpleSalesChannelFactory(dbConnection, {
sc = await breaking(
async () => {
return await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
},
async () => {
return await salesChannelService.create({
name: "test name",
description: "test description",
})
afterEach(async () => {
const db = useDb()
await db.teardown()
}
)
})
it("updates sales channel properties", async () => {
const api = useApi()
const payload = {
name: "updated name",
description: "updated description",
@@ -212,11 +241,7 @@ describe("sales channels", () => {
const response = await api.post(
`/admin/sales-channels/${sc.id}`,
payload,
{
headers: {
"x-medusa-access-token": "test_token",
},
}
adminReqConfig
)
expect(response.status).toEqual(200)
@@ -234,18 +259,9 @@ describe("sales channels", () => {
})
describe("POST /admin/sales-channels", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
beforeEach(async () => {})
it("successfully creates a disabled sales channel", async () => {
const api = useApi()
const newSalesChannel = {
name: "sales channel name",
is_disabled: true,
@@ -269,8 +285,6 @@ describe("sales channels", () => {
})
it("successfully creates a sales channel", async () => {
const api = useApi()
const newSalesChannel = {
name: "sales channel name",
description: "sales channel description",
@@ -299,37 +313,44 @@ describe("sales channels", () => {
let salesChannel
beforeEach(async () => {
await adminSeeder(dbConnection)
salesChannel = await simpleSalesChannelFactory(dbConnection, {
salesChannel = await breaking(
async () => {
return await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
},
async () => {
return await salesChannelService.create({
name: "test name",
description: "test description",
})
}
)
await breaking(async () => {
await simpleSalesChannelFactory(dbConnection, {
name: "Default channel",
id: "test-channel",
is_default: true,
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should delete the requested sales channel", async () => {
const api = useApi()
let deletedSalesChannel = await dbConnection.manager.findOne(
SalesChannel,
{
let toDelete = await breaking(
async () => {
return await dbConnection.manager.findOne(SalesChannel, {
where: { id: salesChannel.id },
withDeleted: true,
})
},
async () => {
return await salesChannelService.retrieve(salesChannel.id)
}
)
expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).toEqual(null)
expect(toDelete.id).toEqual(salesChannel.id)
expect(toDelete.deleted_at).toEqual(null)
const response = await api.delete(
`/admin/sales-channels/${salesChannel.id}`,
@@ -343,72 +364,26 @@ describe("sales channels", () => {
object: "sales-channel",
})
deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, {
const deleted = await breaking(
async () => {
return await dbConnection.manager.findOne(SalesChannel, {
where: { id: salesChannel.id },
withDeleted: true,
})
expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).not.toEqual(null)
})
it("should delete the requested sales channel idempotently", async () => {
const api = useApi()
let deletedSalesChannel = await dbConnection.manager.findOne(
SalesChannel,
{
where: { id: salesChannel.id },
},
async () => {
return await salesChannelService.retrieve(salesChannel.id, {
withDeleted: true,
})
}
)
expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).toEqual(null)
let response = await api.delete(
`/admin/sales-channels/${salesChannel.id}`,
adminReqConfig
)
expect(response.status).toEqual(200)
expect(response.data).toEqual({
id: expect.any(String),
object: "sales-channel",
deleted: true,
})
deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, {
where: { id: salesChannel.id },
withDeleted: true,
})
expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).not.toEqual(null)
response = await api.delete(
`/admin/sales-channels/${salesChannel.id}`,
adminReqConfig
)
expect(response.status).toEqual(200)
expect(response.data).toEqual({
id: expect.any(String),
object: "sales-channel",
deleted: true,
})
deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, {
where: { id: salesChannel.id },
withDeleted: true,
})
expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).not.toEqual(null)
expect(deleted.id).toEqual(salesChannel.id)
expect(deleted.deleted_at).not.toEqual(null)
})
it("should throw if we attempt to delete default channel", async () => {
const api = useApi()
await breaking(async () => {
expect.assertions(2)
const res = await api
@@ -421,12 +396,11 @@ describe("sales channels", () => {
)
})
})
})
describe("GET /admin/orders/:id", () => {
let order
beforeEach(async () => {
await adminSeeder(dbConnection)
order = await simpleOrderFactory(dbConnection, {
sales_channel: {
name: "test name",
@@ -443,14 +417,7 @@ describe("sales channels", () => {
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("expands sales channel for single", async () => {
const api = useApi()
const response = await api.get(
`/admin/orders/${order.id}`,
adminReqConfig
@@ -470,8 +437,6 @@ describe("sales channels", () => {
})
it("creates swap with order sales channel", async () => {
const api = useApi()
const product = await simpleProductFactory(dbConnection, {
variants: [{ id: "test-variant", inventory_quantity: 100 }],
})
@@ -502,8 +467,6 @@ describe("sales channels", () => {
})
it("creates swap with provided sales channel", async () => {
const api = useApi()
const sc = await simpleSalesChannelFactory(dbConnection, {})
const product = await simpleProductFactory(dbConnection, {
@@ -537,8 +500,6 @@ describe("sales channels", () => {
describe("GET /admin/orders?expand=sales_channels", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
await simpleOrderFactory(dbConnection, {
sales_channel: {
name: "test name",
@@ -547,14 +508,7 @@ describe("sales channels", () => {
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("expands sales channel with parameter", async () => {
const api = useApi()
const response = await api.get(
"/admin/orders?expand=sales_channel",
adminReqConfig
@@ -577,8 +531,6 @@ describe("sales channels", () => {
describe("GET /admin/product/:id", () => {
let product
beforeEach(async () => {
await adminSeeder(dbConnection)
product = await simpleProductFactory(dbConnection, {
sales_channels: [
{
@@ -593,14 +545,7 @@ describe("sales channels", () => {
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("returns product with sales channel", async () => {
const api = useApi()
const response = await api
.get(`/admin/products/${product.id}`, adminReqConfig)
.catch((err) => console.log(err))
@@ -625,8 +570,6 @@ describe("sales channels", () => {
describe("GET /admin/products?expand[]=sales_channels", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
await simpleProductFactory(dbConnection, {
sales_channels: [
{
@@ -641,14 +584,7 @@ describe("sales channels", () => {
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("expands sales channel with parameter", async () => {
const api = useApi()
const response = await api.get(
"/admin/products?expand=sales_channels",
adminReqConfig
@@ -677,7 +613,6 @@ describe("sales channels", () => {
let product
beforeEach(async () => {
await adminSeeder(dbConnection)
product = await simpleProductFactory(dbConnection, {
id: "product_1",
title: "test title",
@@ -689,14 +624,7 @@ describe("sales channels", () => {
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should remove products from a sales channel", async () => {
const api = useApi()
let attachedProduct = await dbConnection.manager.findOne(Product, {
where: { id: product.id },
relations: ["sales_channels"],
@@ -763,7 +691,6 @@ describe("sales channels", () => {
let product
beforeEach(async () => {
await adminSeeder(dbConnection)
salesChannel = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
@@ -774,14 +701,7 @@ describe("sales channels", () => {
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should add products to a sales channel", async () => {
const api = useApi()
const payload = {
product_ids: [{ id: product.id }],
}
@@ -834,7 +754,6 @@ describe("sales channels", () => {
let order
beforeEach(async () => {
await adminSeeder(dbConnection)
order = await simpleOrderFactory(dbConnection, {
sales_channel: {
name: "test name",
@@ -844,14 +763,7 @@ describe("sales channels", () => {
await orderSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should successfully lists orders that belongs to the requested sales channels", async () => {
const api = useApi()
const response = await api.get(
`/admin/orders?sales_channel_id[]=${order.sales_channel_id}`,
{
@@ -884,7 +796,6 @@ describe("sales channels", () => {
beforeEach(async () => {
await productSeeder(dbConnection)
await adminSeeder(dbConnection)
const product = await simpleProductFactory(dbConnection, productData)
salesChannel = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
@@ -893,14 +804,7 @@ describe("sales channels", () => {
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should returns a list of products that belongs to the requested sales channels", async () => {
const api = useApi()
const response = await api
.get(`/admin/products?sales_channel_id[]=${salesChannel.id}`, {
headers: {
@@ -929,7 +833,6 @@ describe("sales channels", () => {
beforeEach(async () => {
await productSeeder(dbConnection)
await adminSeeder(dbConnection)
salesChannel = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
@@ -937,14 +840,7 @@ describe("sales channels", () => {
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should creates a product that is assigned to a sales_channel", async () => {
const api = useApi()
const payload = {
title: "Test",
description: "test-product-description",
@@ -985,8 +881,6 @@ describe("sales channels", () => {
})
it("should assign the default sales channel to a product if none is provided when creating it", async () => {
const api = useApi()
const payload = {
title: "Product-no-saleschannel",
description: "test-product-description",
@@ -1031,21 +925,13 @@ describe("sales channels", () => {
beforeEach(async () => {
await productSeeder(dbConnection)
await adminSeeder(dbConnection)
salesChannel = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should update a product sales channels assignation with either a sales channel, null, [] or undefined", async () => {
const api = useApi()
let response = await api
.post(
"/admin/products/test-product",
@@ -1148,8 +1034,6 @@ describe("sales channels", () => {
})
it("should throw on update if the sales channels does not exists", async () => {
const api = useApi()
const err = await api
.post(
"/admin/products/test-product",
@@ -1174,4 +1058,5 @@ describe("sales channels", () => {
})
})
})
},
})
@@ -1,6 +1,6 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICustomerModuleService } from "@medusajs/types"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
type DeleteCustomerStepInput = string[]
+2 -1
View File
@@ -1,5 +1,5 @@
export * from "./auth"
export * from "./api-key"
export * from "./auth"
export * from "./customer"
export * from "./customer-group"
export * from "./definition"
@@ -14,6 +14,7 @@ export * from "./pricing"
export * from "./product"
export * from "./promotion"
export * from "./region"
export * from "./sales-channel"
export * from "./shipping-options"
export * from "./store"
export * from "./tax"
@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"
@@ -0,0 +1,38 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
CreateSalesChannelDTO,
ISalesChannelModuleService,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
data: CreateSalesChannelDTO[]
}
export const createSalesChannelsStepId = "create-sales-channels"
export const createSalesChannelsStep = createStep(
createSalesChannelsStepId,
async (input: StepInput, { container }) => {
const salesChannelService = container.resolve<ISalesChannelModuleService>(
ModuleRegistrationName.SALES_CHANNEL
)
const salesChannels = await salesChannelService.create(input.data)
return new StepResponse(
salesChannels,
salesChannels.map((s) => s.id)
)
},
async (createdIds, { container }) => {
if (!createdIds) {
return
}
const service = container.resolve<ISalesChannelModuleService>(
ModuleRegistrationName.SALES_CHANNEL
)
await service.delete(createdIds)
}
)
@@ -0,0 +1,30 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ISalesChannelModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type DeleteSalesChannelsInput = string[]
export const deleteSalesChannelsStepId = "delete-sales-channels"
export const deleteSalesChannelsStep = createStep(
deleteSalesChannelsStepId,
async (ids: DeleteSalesChannelsInput, { container }) => {
const service = container.resolve<ISalesChannelModuleService>(
ModuleRegistrationName.SALES_CHANNEL
)
await service.softDelete(ids)
return new StepResponse(void 0, ids)
},
async (prevSalesChannelIds, { container }) => {
if (!prevSalesChannelIds?.length) {
return
}
const service = container.resolve<ISalesChannelModuleService>(
ModuleRegistrationName.SALES_CHANNEL
)
await service.restore(prevSalesChannelIds)
}
)
@@ -0,0 +1,3 @@
export * from "./create-sales-channels"
export * from "./update-sales-channels"
export * from "./delete-sales-channels"
@@ -0,0 +1,55 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
FilterableSalesChannelProps,
ISalesChannelModuleService,
UpdateSalesChannelDTO,
} from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type UpdateSalesChannelsStepInput = {
selector: FilterableSalesChannelProps
update: UpdateSalesChannelDTO
}
export const updateSalesChannelsStepId = "update-sales-channels"
export const updateSalesChannelsStep = createStep(
updateSalesChannelsStepId,
async (data: UpdateSalesChannelsStepInput, { container }) => {
const service = container.resolve<ISalesChannelModuleService>(
ModuleRegistrationName.SALES_CHANNEL
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
data.update,
])
const prevData = await service.list(data.selector, {
select: selects,
relations,
})
const channels = await service.update(data.selector, data.update)
return new StepResponse(channels, prevData)
},
async (prevData, { container }) => {
if (!prevData?.length) {
return
}
const service = container.resolve<ISalesChannelModuleService>(
ModuleRegistrationName.SALES_CHANNEL
)
await service.upsert(
prevData.map((r) => ({
id: r.id,
name: r.name,
description: r.description,
is_disabled: r.is_disabled,
metadata: r.metadata,
}))
)
}
)
@@ -0,0 +1,13 @@
import { CreateSalesChannelDTO, SalesChannelDTO } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createSalesChannelsStep } from "../steps/create-sales-channels"
type WorkflowInput = { salesChannelsData: CreateSalesChannelDTO[] }
export const createSalesChannelsWorkflowId = "create-sales-channels"
export const createSalesChannelsWorkflow = createWorkflow(
createSalesChannelsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<SalesChannelDTO[]> => {
return createSalesChannelsStep({ data: input.salesChannelsData })
}
)
@@ -0,0 +1,12 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { deleteSalesChannelsStep } from "../steps/delete-sales-channels"
type WorkflowInput = { ids: string[] }
export const deleteSalesChannelsWorkflowId = "delete-sales-channels"
export const deleteSalesChannelsWorkflow = createWorkflow(
deleteSalesChannelsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<void> => {
return deleteSalesChannelsStep(input.ids)
}
)
@@ -0,0 +1,3 @@
export * from "./create-sales-channels"
export * from "./update-sales-channels"
export * from "./delete-sales-channels"
@@ -0,0 +1,22 @@
import {
FilterableSalesChannelProps,
SalesChannelDTO,
UpdateSalesChannelDTO,
} from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updateSalesChannelsStep } from "../steps/update-sales-channels"
type UpdateSalesChannelsStepInput = {
selector: FilterableSalesChannelProps
update: UpdateSalesChannelDTO
}
export const updateSalesChannelsWorkflowId = "update-sales-channels"
export const updateSalesChannelsWorkflow = createWorkflow(
updateSalesChannelsWorkflowId,
(
input: WorkflowData<UpdateSalesChannelsStepInput>
): WorkflowData<SalesChannelDTO[]> => {
return updateSalesChannelsStep(input)
}
)
@@ -0,0 +1,85 @@
import {
deleteSalesChannelsWorkflow,
updateSalesChannelsWorkflow,
} from "@medusajs/core-flows"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../types/routing"
import { defaultAdminSalesChannelFields } from "../query-config"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const variables = {
id: req.params.id,
}
const queryObject = remoteQueryObjectFromString({
entryPoint: "sales_channels",
variables,
fields: defaultAdminSalesChannelFields,
})
const [sales_channel] = await remoteQuery(queryObject)
res.json({ sales_channel })
}
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const { errors } = await updateSalesChannelsWorkflow(req.scope).run({
input: {
selector: { id: req.params.id },
update: req.validatedBody,
},
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "sales_channels",
variables: { id: req.params.id },
fields: defaultAdminSalesChannelFields,
})
const [sales_channel] = await remoteQuery(queryObject)
res.status(200).json({ sales_channel })
}
export const DELETE = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const id = req.params.id
const { errors } = await deleteSalesChannelsWorkflow(req.scope).run({
input: { ids: [id] },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({
id,
object: "sales-channel",
deleted: true,
})
}
@@ -0,0 +1,59 @@
import { transformBody, transformQuery } from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware"
import * as QueryConfig from "./query-config"
import {
AdminGetSalesChannelsParams,
AdminGetSalesChannelsSalesChannelParams,
AdminPostSalesChannelsReq,
AdminPostSalesChannelsSalesChannelReq,
} from "./validators"
export const adminSalesChannelRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["ALL"],
matcher: "/admin/sales-channels*",
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
},
{
method: ["GET"],
matcher: "/admin/sales-channels",
middlewares: [
transformQuery(
AdminGetSalesChannelsParams,
QueryConfig.listTransformQueryConfig
),
],
},
{
method: ["GET"],
matcher: "/admin/sales-channels/:id",
middlewares: [
transformQuery(
AdminGetSalesChannelsSalesChannelParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/sales-channels",
middlewares: [
transformBody(AdminPostSalesChannelsReq),
transformQuery(
AdminGetSalesChannelsSalesChannelParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/sales-channels/:id",
middlewares: [transformBody(AdminPostSalesChannelsSalesChannelReq)],
},
{
method: ["DELETE"],
matcher: "/admin/sales-channels/:id",
middlewares: [],
},
]
@@ -0,0 +1,19 @@
export const defaultAdminSalesChannelFields = [
"id",
"name",
"description",
"is_disabled",
"created_at",
"updated_at",
"deleted_at",
]
export const retrieveTransformQueryConfig = {
defaults: defaultAdminSalesChannelFields,
isList: false,
}
export const listTransformQueryConfig = {
...retrieveTransformQueryConfig,
isList: true,
}
@@ -0,0 +1,65 @@
import { createSalesChannelsWorkflow } from "@medusajs/core-flows"
import { CreateSalesChannelDTO } from "@medusajs/types"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const variables = {
filters: req.filterableFields,
...req.remoteQueryConfig.pagination,
}
const queryObject = remoteQueryObjectFromString({
entryPoint: "sales_channels",
variables,
fields: req.remoteQueryConfig.fields,
})
const { rows: sales_channels, metadata } = await remoteQuery(queryObject)
res.json({
sales_channels,
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
}
export const POST = async (
req: AuthenticatedMedusaRequest<CreateSalesChannelDTO>,
res: MedusaResponse
) => {
const salesChannelsData = [req.validatedBody]
const { errors } = await createSalesChannelsWorkflow(req.scope).run({
input: { salesChannelsData },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "sales_channels",
variables: { id: req.params.id },
fields: req.remoteQueryConfig.fields,
})
const [sales_channel] = await remoteQuery(queryObject)
res.status(200).json({ sales_channel })
}
@@ -0,0 +1,102 @@
import { OperatorMap } from "@medusajs/types"
import { Type } from "class-transformer"
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { FindParams, extendedFindParamsMixin } from "../../../types/common"
import { OperatorMapValidator } from "../../../types/validators/operator-map"
export class AdminGetSalesChannelsSalesChannelParams extends FindParams {}
export class AdminGetSalesChannelsParams extends extendedFindParamsMixin({
limit: 20,
offset: 0,
}) {
/**
* ID to filter sales channels by.
*/
@IsString()
@IsOptional()
id?: string
/**
* Name to filter sales channels by.
*/
@IsOptional()
@IsString()
name?: string
/**
* Description to filter sales channels by.
*/
@IsOptional()
@IsString()
description?: string
/**
* Date filters to apply on sales channels' `created_at` field.
*/
@IsOptional()
@ValidateNested()
@Type(() => OperatorMapValidator)
created_at?: OperatorMap<string>
/**
* Date filters to apply on sales channels' `updated_at` field.
*/
@IsOptional()
@ValidateNested()
@Type(() => OperatorMapValidator)
updated_at?: OperatorMap<string>
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AdminGetSalesChannelsParams)
$and?: AdminGetSalesChannelsParams[]
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AdminGetSalesChannelsParams)
$or?: AdminGetSalesChannelsParams[]
}
export class AdminPostSalesChannelsReq {
@IsString()
name: string
@IsString()
@IsOptional()
description: string
@IsBoolean()
@IsOptional()
is_disabled?: boolean
@IsNotEmpty()
@IsString()
@IsOptional()
metadata?: Record<string, unknown>
}
export class AdminPostSalesChannelsSalesChannelReq {
@IsOptional()
@IsString()
name?: string
@IsOptional()
@IsString()
description?: string
@IsBoolean()
@IsOptional()
is_disabled?: boolean
@IsNotEmpty()
@IsString()
@IsOptional()
metadata?: Record<string, unknown>
}
@@ -14,6 +14,7 @@ import { adminPricingRoutesMiddlewares } from "./admin/pricing/middlewares"
import { adminProductRoutesMiddlewares } from "./admin/products/middlewares"
import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares"
import { adminRegionRoutesMiddlewares } from "./admin/regions/middlewares"
import { adminSalesChannelRoutesMiddlewares } from "./admin/sales-channels/middlewares"
import { adminStoreRoutesMiddlewares } from "./admin/stores/middlewares"
import { adminTaxRateRoutesMiddlewares } from "./admin/tax-rates/middlewares"
import { adminTaxRegionRoutesMiddlewares } from "./admin/tax-regions/middlewares"
@@ -55,5 +56,6 @@ export const config: MiddlewaresConfig = {
...adminCollectionRoutesMiddlewares,
...adminPricingRoutesMiddlewares,
...adminFulfillmentRoutesMiddlewares,
...adminSalesChannelRoutesMiddlewares,
],
}
@@ -4,8 +4,8 @@ import { ISalesChannelModuleService } from "@medusajs/types"
import { initialize } from "../../../src"
import { DB_URL, MikroOrmWrapper } from "../../utils"
import { createSalesChannels } from "../../__fixtures__"
import { DB_URL, MikroOrmWrapper } from "../../utils"
jest.setTimeout(30000)
@@ -84,13 +84,10 @@ describe("Sales Channel Service", () => {
const id = "channel-2"
it("should update the name of the SalesChannel successfully", async () => {
await service.update([
{
id,
await service.update(id, {
name: "Update name 2",
is_disabled: true,
},
])
})
const channel = await service.retrieve(id)
@@ -102,11 +99,9 @@ describe("Sales Channel Service", () => {
let error
try {
await service.update([
{
id: "does-not-exist",
},
])
await service.update("does-not-exist", {
name: "does-not-exist",
})
} catch (e) {
error = e
}
@@ -43,6 +43,15 @@
"default": "false",
"mappedType": "boolean"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
@@ -3,7 +3,7 @@ import { Migration } from "@mikro-orm/migrations"
export class Migration20240115152146 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table if not exists "sales_channel" ("id" text not null, "name" text not null, "description" text null, "is_disabled" boolean not null default false, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "sales_channel_pkey" primary key ("id"));'
'create table if not exists "sales_channel" ("id" text not null, "name" text not null, "description" text null, "is_disabled" 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, constraint "sales_channel_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_sales_channel_deleted_at" on "sales_channel" ("deleted_at");'
@@ -38,6 +38,9 @@ export default class SalesChannel {
})
created_at: Date
@Property({ columnType: "jsonb", nullable: true })
metadata: Record<string, unknown> | null = null
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
@@ -2,6 +2,7 @@ import {
Context,
CreateSalesChannelDTO,
DAL,
FilterableSalesChannelProps,
InternalModuleDeclaration,
ISalesChannelModuleService,
ModuleJoinerConfig,
@@ -9,10 +10,19 @@ import {
SalesChannelDTO,
UpdateSalesChannelDTO,
} from "@medusajs/types"
import { MedusaContext, ModulesSdkUtils } from "@medusajs/utils"
import {
InjectManager,
InjectTransactionManager,
isString,
MedusaContext,
ModulesSdkUtils,
promiseAll,
} from "@medusajs/utils"
import { SalesChannel } from "@models"
import { UpsertSalesChannelDTO } from "@medusajs/types"
import { UpdateSalesChanneInput } from "@types"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
type InjectedDependencies = {
@@ -51,19 +61,18 @@ export default class SalesChannelModuleService<
data: CreateSalesChannelDTO[],
sharedContext?: Context
): Promise<SalesChannelDTO[]>
async create(
data: CreateSalesChannelDTO,
sharedContext?: Context
): Promise<SalesChannelDTO>
@InjectManager("baseRepository_")
async create(
data: CreateSalesChannelDTO | CreateSalesChannelDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<SalesChannelDTO | SalesChannelDTO[]> {
const input = Array.isArray(data) ? data : [data]
const result = await this.salesChannelService_.create(input, sharedContext)
const result = await this.create_(input, sharedContext)
return await this.baseRepository_.serialize<SalesChannelDTO[]>(
Array.isArray(data) ? result : result[0],
@@ -73,23 +82,47 @@ export default class SalesChannelModuleService<
)
}
async update(
data: UpdateSalesChannelDTO[],
sharedContext?: Context
): Promise<SalesChannelDTO[]>
@InjectTransactionManager("baseRepository_")
async create_(
data: CreateSalesChannelDTO[],
@MedusaContext() sharedContext: Context
): Promise<SalesChannel[]> {
return await this.salesChannelService_.create(data, sharedContext)
}
async update(
id: string,
data: UpdateSalesChannelDTO,
sharedContext?: Context
): Promise<SalesChannelDTO>
async update(
selector: FilterableSalesChannelProps,
data: UpdateSalesChannelDTO,
sharedContext?: Context
): Promise<SalesChannelDTO[]>
@InjectManager("baseRepository_")
async update(
idOrSelector: string | FilterableSalesChannelProps,
data: UpdateSalesChannelDTO | UpdateSalesChannelDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<SalesChannelDTO | SalesChannelDTO[]> {
const input = Array.isArray(data) ? data : [data]
let normalizedInput: UpdateSalesChanneInput[] = []
if (isString(idOrSelector)) {
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const channels = await this.salesChannelService_.list(
idOrSelector,
{},
sharedContext
)
const result = await this.salesChannelService_.update(input, sharedContext)
normalizedInput = channels.map((channel) => ({
id: channel.id,
...data,
}))
}
const result = await this.update_(normalizedInput, sharedContext)
return await this.baseRepository_.serialize<SalesChannelDTO[]>(
Array.isArray(data) ? result : result[0],
@@ -98,4 +131,45 @@ export default class SalesChannelModuleService<
}
)
}
@InjectTransactionManager("baseRepository_")
async update_(data: UpdateSalesChannelDTO[], sharedContext: Context) {
return await this.salesChannelService_.update(data, sharedContext)
}
async upsert(
data: UpsertSalesChannelDTO[],
sharedContext?: Context
): Promise<SalesChannelDTO[]>
async upsert(
data: UpsertSalesChannelDTO,
sharedContext?: Context
): Promise<SalesChannelDTO>
@InjectTransactionManager("baseRepository_")
async upsert(
data: UpsertSalesChannelDTO | UpsertSalesChannelDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<SalesChannelDTO | SalesChannelDTO[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(channel): channel is UpdateSalesChannelDTO => !!channel.id
)
const forCreate = input.filter(
(channel): channel is CreateSalesChannelDTO => !channel.id
)
const operations: Promise<SalesChannel[]>[] = []
if (forCreate.length) {
operations.push(this.create_(forCreate, sharedContext))
}
if (forUpdate.length) {
operations.push(this.update_(forUpdate, sharedContext))
}
const result = (await promiseAll(operations)).flat()
return await this.baseRepository_.serialize<
SalesChannelDTO[] | SalesChannelDTO
>(Array.isArray(data) ? result : result[0])
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
import { Logger } from "@medusajs/types"
import { Logger, UpdateSalesChannelDTO } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
logger?: Logger
}
export type UpdateSalesChanneInput = UpdateSalesChannelDTO & { id: string }
+2 -2
View File
@@ -63,12 +63,12 @@ export interface FilterableSalesChannelProps
/**
* The IDs to filter the sales channel by.
*/
id?: string[]
id?: string | string[]
/**
* Filter sales channels by their names.
*/
name?: string[]
name?: string | string[]
/**
* Filter sales channels by whether they're disabled.
+30 -5
View File
@@ -22,11 +22,6 @@ export interface CreateSalesChannelDTO {
* The attributes to update in the sales channel.
*/
export interface UpdateSalesChannelDTO {
/**
* The ID of the sales channel.
*/
id: string
/**
* The name of the sales channel.
*/
@@ -41,4 +36,34 @@ export interface UpdateSalesChannelDTO {
* Whether the sales channel is disabled.
*/
is_disabled?: boolean
/**
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown>
}
export interface UpsertSalesChannelDTO {
/**
* The ID of the sales channel.
*/
id?: string
/**
* The name of the sales channel.
*/
name?: string
/**
* The description of the sales channel.
*/
description?: string | null
/**
* Whether the sales channel is disabled.
*/
is_disabled?: boolean
/**
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown> | null
}
+19 -29
View File
@@ -1,9 +1,9 @@
import { IModuleService } from "../modules-sdk"
import { FilterableSalesChannelProps, SalesChannelDTO } from "./common"
import { FindConfig } from "../common"
import { Context } from "../shared-context"
import { RestoreReturn, SoftDeleteReturn } from "../dal"
import { CreateSalesChannelDTO, UpdateSalesChannelDTO } from "./mutations"
import { IModuleService } from "../modules-sdk"
import { Context } from "../shared-context"
import { FilterableSalesChannelProps, SalesChannelDTO } from "./common"
import { CreateSalesChannelDTO, UpdateSalesChannelDTO, UpsertSalesChannelDTO } from "./mutations"
/**
* The main service interface for the sales channel module.
@@ -39,35 +39,25 @@ export interface ISalesChannelModuleService extends IModuleService {
sharedContext?: Context
): Promise<SalesChannelDTO>
/**
* This method updates existing sales channels.
*
* @param {UpdateSalesChannelDTO[]} data - The attributes to update in each sales channel.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<SalesChannelDTO[]>} The updated sales channels.
*
* @example
* {example-code}
*/
update(
data: UpdateSalesChannelDTO[],
sharedContext?: Context
): Promise<SalesChannelDTO[]>
/**
* This method updates an existing sales channel.
*
* @param {UpdateSalesChannelDTO} data - The attributes to update in the sales channel.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<SalesChannelDTO>} The updated sales channel.
*
* @example
* {example-code}
*/
update(
channelId: string,
data: UpdateSalesChannelDTO,
sharedContext?: Context
): Promise<SalesChannelDTO>
update(
selector: FilterableSalesChannelProps,
data: UpdateSalesChannelDTO,
sharedContext?: Context
): Promise<SalesChannelDTO[]>
upsert(
data: UpsertSalesChannelDTO,
sharedContext?: Context
): Promise<SalesChannelDTO>
upsert(
data: UpsertSalesChannelDTO[],
sharedContext?: Context
): Promise<SalesChannelDTO[]>
/**
* This method deletes sales channels by their IDs.