feat(core-flows,modules-sdk,types,medusa,link-modules): adds variant <> inventory item link endpoints (#7576)

what:

- adds variant inventory link management endpoints:
```
Link inventory item to variant
POST /products/:id/variants/:vid/inventory-items

Update variant's inventory item link
POST /products/:id/variants/:vid/inventory-items/:iid

Unlink variant's inventory item
DELETE /products/:id/variants/:vid/inventory-items/:iid
```

- a batch endpoint that does the above 3 across variants
```
POST /products/:id/variants/inventory-items
```
This commit is contained in:
Riqwan Thamir
2024-06-03 20:23:29 +02:00
committed by GitHub
parent 122186a78d
commit ecfbfcc707
23 changed files with 1279 additions and 126 deletions

View File

@@ -1,8 +1,8 @@
import {
createAdminUser,
adminHeaders,
} from "../../../../helpers/create-admin-user"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import {
adminHeaders,
createAdminUser,
} from "../../../../helpers/create-admin-user"
jest.setTimeout(50000)
@@ -2870,6 +2870,298 @@ medusaIntegrationTestRunner({
)
})
})
describe("POST /admin/products/:id/variants/:variant_id/inventory-items", () => {
it("should throw an error when required attributes are not passed", async () => {
const { response } = await api
.post(
`/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`,
{},
adminHeaders
)
.catch((e) => e)
expect(response.status).toEqual(400)
expect(response.data).toEqual({
type: "invalid_data",
message:
"Invalid request: Field 'required_quantity' is required; Field 'inventory_item_id' is required",
})
})
it("successfully adds inventory item to a variant", async () => {
const inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
const res = await api.post(
`/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items?fields=inventory_items.inventory.*,inventory_items.*`,
{
inventory_item_id: inventoryItem.id,
required_quantity: 5,
},
adminHeaders
)
expect(res.status).toEqual(200)
expect(res.data.variant.inventory_items[0]).toEqual(
expect.objectContaining({
required_quantity: 5,
inventory_item_id: inventoryItem.id,
})
)
})
})
describe("POST /admin/products/:id/variants/:variant_id/inventory-items/:inventory_id", () => {
let inventoryItem
beforeEach(async () => {
inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`,
{
inventory_item_id: inventoryItem.id,
required_quantity: 5,
},
adminHeaders
)
})
it("should throw an error when required attributes are not passed", async () => {
const { response } = await api
.post(
`/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}`,
{},
adminHeaders
)
.catch((e) => e)
expect(response.status).toEqual(400)
expect(response.data).toEqual({
type: "invalid_data",
message: "Invalid request: Field 'required_quantity' is required",
})
})
it("successfully updates an inventory item link to a variant", async () => {
const res = await api.post(
`/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}?fields=inventory_items.inventory.*,inventory_items.*`,
{ required_quantity: 10 },
adminHeaders
)
expect(res.status).toEqual(200)
expect(res.data.variant.inventory_items)
expect(res.data.variant.inventory_items).toEqual([
expect.objectContaining({
required_quantity: 10,
inventory_item_id: inventoryItem.id,
}),
])
})
})
describe("DELETE /admin/products/:id/variants/:variant_id/inventory-items/:inventory_id", () => {
let inventoryItem
beforeEach(async () => {
inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`,
{
inventory_item_id: inventoryItem.id,
required_quantity: 5,
},
adminHeaders
)
})
it("successfully deletes an inventory item link from a variant", async () => {
await api.post(
`/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items`,
{ inventory_item_id: inventoryItem.id, required_quantity: 5 },
adminHeaders
)
const res = await api.delete(
`/admin/products/${baseProduct.id}/variants/${baseProduct.variants[0].id}/inventory-items/${inventoryItem.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
expect(res.status).toEqual(200)
expect(res.data.parent.inventory_items)
expect(res.data.parent.inventory_items).toEqual([])
})
})
describe("POST /admin/products/:id/variants/:variant_id/inventory-items/batch", () => {
let inventoryItemToUpdate
let inventoryItemToDelete
let inventoryItemToCreate
let inventoryProduct
let inventoryVariant1
let inventoryVariant2
let inventoryVariant3
beforeEach(async () => {
inventoryProduct = (
await api.post(
"/admin/products",
{
title: "product 1",
variants: [
{
title: "variant 1",
prices: [{ currency_code: "usd", amount: 100 }],
},
{
title: "variant 2",
prices: [{ currency_code: "usd", amount: 100 }],
},
{
title: "variant 3",
prices: [{ currency_code: "usd", amount: 100 }],
},
],
},
adminHeaders
)
).data.product
inventoryVariant1 = inventoryProduct.variants[0]
inventoryVariant2 = inventoryProduct.variants[1]
inventoryVariant3 = inventoryProduct.variants[2]
inventoryItemToCreate = (
await api.post(
`/admin/inventory-items`,
{ sku: "to-create" },
adminHeaders
)
).data.inventory_item
inventoryItemToUpdate = (
await api.post(
`/admin/inventory-items`,
{ sku: "to-update" },
adminHeaders
)
).data.inventory_item
inventoryItemToDelete = (
await api.post(
`/admin/inventory-items`,
{ sku: "to-delete" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/products/${baseProduct.id}/variants/${inventoryVariant1.id}/inventory-items`,
{
inventory_item_id: inventoryItemToUpdate.id,
required_quantity: 5,
},
adminHeaders
)
await api.post(
`/admin/products/${baseProduct.id}/variants/${inventoryVariant2.id}/inventory-items`,
{
inventory_item_id: inventoryItemToDelete.id,
required_quantity: 10,
},
adminHeaders
)
})
it("successfully creates, updates and deletes an inventory item link from a variant", async () => {
const res = await api.post(
`/admin/products/${baseProduct.id}/variants/inventory-items/batch`,
{
create: [
{
required_quantity: 15,
inventory_item_id: inventoryItemToCreate.id,
variant_id: inventoryVariant3.id,
},
],
update: [
{
required_quantity: 25,
inventory_item_id: inventoryItemToUpdate.id,
variant_id: inventoryVariant1.id,
},
],
delete: [
{
inventory_item_id: inventoryItemToDelete.id,
variant_id: inventoryVariant2.id,
},
],
},
adminHeaders
)
expect(res.status).toEqual(200)
const createdLinkVariant = (
await api.get(
`/admin/products/${baseProduct.id}/variants/${inventoryVariant3.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(createdLinkVariant.inventory_items[0]).toEqual(
expect.objectContaining({
required_quantity: 15,
inventory_item_id: inventoryItemToCreate.id,
})
)
const updatedLinkVariant = (
await api.get(
`/admin/products/${baseProduct.id}/variants/${inventoryVariant1.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedLinkVariant.inventory_items[0]).toEqual(
expect.objectContaining({
required_quantity: 25,
inventory_item_id: inventoryItemToUpdate.id,
})
)
const deletedLinkVariant = (
await api.get(
`/admin/products/${baseProduct.id}/variants/${inventoryVariant2.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(deletedLinkVariant.inventory_items).toHaveLength(0)
})
})
})
},
})

View File

@@ -0,0 +1,313 @@
import {
createLinksWorkflow,
createLinksWorkflowId,
dismissLinksWorkflow,
dismissLinksWorkflowId,
updateLinksWorkflow,
updateLinksWorkflowId,
} from "@medusajs/core-flows"
import { Modules } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "medusa-test-utils/dist"
import {
adminHeaders,
createAdminUser,
} from "../../../helpers/create-admin-user"
jest.setTimeout(50000)
medusaIntegrationTestRunner({
env: {},
testSuite: ({ getContainer, api, dbConnection }) => {
describe("Workflows: Common", () => {
let appContainer
let product
let variant
beforeAll(async () => {
appContainer = getContainer()
})
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer())
product = (
await api.post(
"/admin/products",
{
title: "product 1",
variants: [
{
title: "variant 1",
prices: [{ currency_code: "usd", amount: 100 }],
},
],
},
adminHeaders
)
).data.product
variant = product.variants[0]
})
describe("createLinksWorkflow", () => {
describe("compensation", () => {
it("should dismiss links when step throws an error", async () => {
const workflow = createLinksWorkflow(appContainer)
const workflowId = createLinksWorkflowId
const inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: { inventory_item_id: inventoryItem.id },
data: { required_quantity: 10 },
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: expect.objectContaining({
message: `Fail`,
}),
},
])
const updatedVariant = (
await api.get(
`/admin/products/${product.id}/variants/${variant.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedVariant.inventory_items).toHaveLength(0)
})
})
})
describe("updateLinksWorkflow", () => {
describe("compensation", () => {
it("should revert link data when step throws an error", async () => {
const workflow = updateLinksWorkflow(appContainer)
const workflowId = updateLinksWorkflowId
const originalQuantity = 5
const newQuantity = 10
const inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/products/${product.id}/variants/${variant.id}/inventory-items`,
{
inventory_item_id: inventoryItem.id,
required_quantity: originalQuantity,
},
adminHeaders
)
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: { inventory_item_id: inventoryItem.id },
data: { required_quantity: newQuantity },
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: expect.objectContaining({
message: `Fail`,
}),
},
])
const updatedVariant = (
await api.get(
`/admin/products/${product.id}/variants/${variant.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedVariant.inventory_items).toEqual([
expect.objectContaining({
required_quantity: originalQuantity,
}),
])
})
it("should throw an error when a link is not found", async () => {
const workflow = updateLinksWorkflow(appContainer)
const workflowId = updateLinksWorkflowId
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: {
inventory_item_id: "does-not-exist-id",
},
data: { required_quantity: 10 },
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "update-remote-links-step",
handlerType: "invoke",
error: expect.objectContaining({
message: `Could not find all existing links from data`,
}),
},
])
})
})
})
describe("dismissLinksWorkflow", () => {
describe("compensation", () => {
it("should recreate dismissed links when step throws an error", async () => {
const originalQuantity = 10
const workflow = dismissLinksWorkflow(appContainer)
const workflowId = dismissLinksWorkflowId
const inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "12345" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/products/${product.id}/variants/${variant.id}/inventory-items`,
{
inventory_item_id: inventoryItem.id,
required_quantity: originalQuantity,
},
adminHeaders
)
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: { inventory_item_id: inventoryItem.id },
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: expect.objectContaining({
message: `Fail`,
}),
},
])
const updatedVariant = (
await api.get(
`/admin/products/${product.id}/variants/${variant.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedVariant.inventory_items).toEqual([
expect.objectContaining({
required_quantity: originalQuantity,
}),
])
})
it("should pass dismiss step if link not found if next step throws error", async () => {
const workflow = dismissLinksWorkflow(appContainer)
const workflowId = dismissLinksWorkflowId
workflow.appendAction("throw", workflowId, {
invoke: async function failStep() {
throw new Error(`Fail`)
},
})
const { errors } = await workflow.run({
input: [
{
[Modules.PRODUCT]: { variant_id: variant.id },
[Modules.INVENTORY]: {
inventory_item_id: "does-not-exist-id",
},
},
],
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: expect.objectContaining({
message: `Fail`,
}),
},
])
const updatedVariant = (
await api.get(
`/admin/products/${product.id}/variants/${variant.id}?fields=inventory_items.inventory.*,inventory_items.*`,
adminHeaders
)
).data.variant
expect(updatedVariant.inventory_items).toEqual([])
})
})
})
})
},
})

View File

@@ -1,4 +1,8 @@
export * from "./steps/remove-remote-links"
export * from "./steps/use-remote-query"
export * from "./steps/create-remote-links"
export * from "./steps/dismiss-remote-links"
export * from "./steps/remove-remote-links"
export * from "./steps/use-remote-query"
export * from "./workflows/batch-links"
export * from "./workflows/create-links"
export * from "./workflows/dismiss-links"
export * from "./workflows/update-links"

View File

@@ -1,14 +1,11 @@
import { LinkDefinition, RemoteLink } from "@medusajs/modules-sdk"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils"
type CreateRemoteLinksStepInput = LinkDefinition[]
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
export const createLinksStepId = "create-remote-links"
export const createRemoteLinkStep = createStep(
createLinksStepId,
async (data: CreateRemoteLinksStepInput, { container }) => {
async (data: LinkDefinition[], { container }) => {
const link = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)

View File

@@ -5,6 +5,7 @@ import { ContainerRegistrationKeys } from "@medusajs/utils"
type DismissRemoteLinksStepInput = LinkDefinition | LinkDefinition[]
// TODO: add ability for this step to restore links from only foreign keys
export const dismissRemoteLinkStepId = "dismiss-remote-links"
export const dismissRemoteLinkStep = createStep(
dismissRemoteLinkStepId,
@@ -18,18 +19,27 @@ export const dismissRemoteLinkStep = createStep(
const link = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)
// Our current revert strategy for dismissed links are to recreate it again.
// This works when its just the primary keys, but when you have additional data
// in the links, we need to preserve them in order to recreate the links accurately.
const dataBeforeDismiss = (await link.list(data, {
asLinkDefinition: true,
})) as LinkDefinition[]
await link.dismiss(entries)
return new StepResponse(entries, entries)
return new StepResponse(entries, dataBeforeDismiss)
},
async (dismissdLinks, { container }) => {
if (!dismissdLinks) {
async (dataBeforeDismiss, { container }) => {
if (!dataBeforeDismiss?.length) {
return
}
const link = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)
await link.create(dismissdLinks)
await link.create(dataBeforeDismiss)
}
)

View File

@@ -0,0 +1,48 @@
import { LinkDefinition, RemoteLink } from "@medusajs/modules-sdk"
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/utils"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
export const updateRemoteLinksStepId = "update-remote-links-step"
export const updateRemoteLinksStep = createStep(
updateRemoteLinksStepId,
async (data: LinkDefinition[], { container }) => {
if (!data.length) {
return new StepResponse([], [])
}
const link = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)
// Fetch all existing links and throw an error if any weren't found
const dataBeforeUpdate = (await link.list(data, {
asLinkDefinition: true,
})) as LinkDefinition[]
const unequal = dataBeforeUpdate.length !== data.length
if (unequal) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Could not find all existing links from data`
)
}
// link.create here performs an upsert. By performing validation above, we can ensure
// that this method will always perform an update in these cases
await link.create(data)
return new StepResponse(data, dataBeforeUpdate)
},
async (dataBeforeUpdate, { container }) => {
if (!dataBeforeUpdate?.length) {
return
}
const link = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)
await link.create(dataBeforeUpdate)
}
)

View File

@@ -0,0 +1,32 @@
import { LinkDefinition } from "@medusajs/modules-sdk"
import { BatchWorkflowInput } from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
parallelize,
} from "@medusajs/workflows-sdk"
import { createRemoteLinkStep } from "../steps/create-remote-links"
import { dismissRemoteLinkStep } from "../steps/dismiss-remote-links"
import { updateRemoteLinksStep } from "../steps/update-remote-links"
export const batchLinksWorkflowId = "batch-links"
export const batchLinksWorkflow = createWorkflow(
batchLinksWorkflowId,
(
input: WorkflowData<
BatchWorkflowInput<LinkDefinition, LinkDefinition, LinkDefinition>
>
) => {
const [created, updated, deleted] = parallelize(
createRemoteLinkStep(input.create || []),
updateRemoteLinksStep(input.update || []),
dismissRemoteLinkStep(input.delete || [])
)
return {
created,
updated,
deleted,
}
}
)

View File

@@ -0,0 +1,11 @@
import { LinkDefinition } from "@medusajs/modules-sdk"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createRemoteLinkStep } from "../steps/create-remote-links"
export const createLinksWorkflowId = "create-link"
export const createLinksWorkflow = createWorkflow(
createLinksWorkflowId,
(input: WorkflowData<LinkDefinition[]>) => {
return createRemoteLinkStep(input)
}
)

View File

@@ -0,0 +1,11 @@
import { LinkDefinition } from "@medusajs/modules-sdk"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { dismissRemoteLinkStep } from "../steps/dismiss-remote-links"
export const dismissLinksWorkflowId = "dismiss-link"
export const dismissLinksWorkflow = createWorkflow(
dismissLinksWorkflowId,
(input: WorkflowData<LinkDefinition[]>) => {
return dismissRemoteLinkStep(input)
}
)

View File

@@ -0,0 +1,11 @@
import { LinkDefinition } from "@medusajs/modules-sdk"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updateRemoteLinksStep } from "../steps/update-remote-links"
export const updateLinksWorkflowId = "update-link"
export const updateLinksWorkflow = createWorkflow(
updateLinksWorkflowId,
(input: WorkflowData<LinkDefinition[]>) => {
return updateRemoteLinksStep(input)
}
)

View File

@@ -7,6 +7,7 @@ import {
import { isObject, promiseAll, toPascalCase } from "@medusajs/utils"
import { Modules } from "./definitions"
import { MedusaModule } from "./medusa-module"
import { convertRecordsToLinkDefinition } from "./utils/convert-data-to-link-definition"
import { linkingErrorMessage } from "./utils/linking-error"
export type DeleteEntityInput = {
@@ -16,7 +17,8 @@ export type RestoreEntityInput = DeleteEntityInput
export type LinkDefinition = {
[moduleName: string]: {
[fieldName: string]: string
// TODO: changing this to any temporarily as the "data" attribute is not being picked up correctly
[fieldName: string]: any
}
} & {
data?: Record<string, unknown>
@@ -41,6 +43,14 @@ type CascadeError = {
error: Error
}
type LinkDataConfig = {
moduleA: string
moduleB: string
primaryKeys: string[]
moduleAKey: string
moduleBKey: string
}
export class RemoteLink {
private modulesMap: Map<string, LoadedLinkModule> = new Map()
private relationsPairs: Map<string, LoadedLinkModule> = new Map()
@@ -325,6 +335,48 @@ export class RemoteLink {
return [errors.length ? errors : null, result]
}
private getLinkModuleOrThrow(link: LinkDefinition): LoadedLinkModule {
const mods = Object.keys(link).filter((attr) => attr !== "data")
if (mods.length > 2) {
throw new Error(`Only two modules can be linked.`)
}
const { moduleA, moduleB, moduleAKey, moduleBKey } =
this.getLinkDataConfig(link)
const service = this.getLinkModule(moduleA, moduleAKey, moduleB, moduleBKey)
if (!service) {
throw new Error(
linkingErrorMessage({
moduleA,
moduleAKey,
moduleB,
moduleBKey,
type: "link",
})
)
}
return service
}
private getLinkDataConfig(link: LinkDefinition): LinkDataConfig {
const moduleNames = Object.keys(link).filter((attr) => attr !== "data")
const [moduleA, moduleB] = moduleNames
const primaryKeys = Object.keys(link[moduleA])
const moduleAKey = primaryKeys.join(",")
const moduleBKey = Object.keys(link[moduleB]).join(",")
return {
moduleA,
moduleB,
primaryKeys,
moduleAKey,
moduleBKey,
}
}
async create(link: LinkDefinition | LinkDefinition[]): Promise<unknown[]> {
const allLinks = Array.isArray(link) ? link : [link]
const serviceLinks = new Map<
@@ -332,114 +384,72 @@ export class RemoteLink {
[string | string[], string, Record<string, unknown>?][]
>()
for (const rel of allLinks) {
const extraFields = rel.data
delete rel.data
for (const link of allLinks) {
const service = this.getLinkModuleOrThrow(link)
const { moduleA, moduleB, moduleBKey, primaryKeys } =
this.getLinkDataConfig(link)
const mods = Object.keys(rel)
if (mods.length > 2) {
throw new Error(`Only two modules can be linked.`)
}
const [moduleA, moduleB] = mods
const pk = Object.keys(rel[moduleA])
const moduleAKey = pk.join(",")
const moduleBKey = Object.keys(rel[moduleB]).join(",")
const service = this.getLinkModule(
moduleA,
moduleAKey,
moduleB,
moduleBKey
)
if (!service) {
throw new Error(
linkingErrorMessage({
moduleA,
moduleAKey,
moduleB,
moduleBKey,
type: "link",
})
)
} else if (!serviceLinks.has(service.__definition.key)) {
if (!serviceLinks.has(service.__definition.key)) {
serviceLinks.set(service.__definition.key, [])
}
const pkValue =
pk.length === 1 ? rel[moduleA][pk[0]] : pk.map((k) => rel[moduleA][k])
primaryKeys.length === 1
? link[moduleA][primaryKeys[0]]
: primaryKeys.map((k) => link[moduleA][k])
const fields: unknown[] = [pkValue, rel[moduleB][moduleBKey]]
if (isObject(extraFields)) {
fields.push(extraFields)
const fields: unknown[] = [pkValue, link[moduleB][moduleBKey]]
if (isObject(link.data)) {
fields.push(link.data)
}
serviceLinks.get(service.__definition.key)?.push(fields as any)
}
const promises: Promise<unknown[]>[] = []
for (const [serviceName, links] of serviceLinks) {
const service = this.modulesMap.get(serviceName)!
promises.push(service.create(links))
}
const created = await promiseAll(promises)
return created.flat()
return (await promiseAll(promises)).flat()
}
async dismiss(link: LinkDefinition | LinkDefinition[]): Promise<unknown[]> {
const allLinks = Array.isArray(link) ? link : [link]
const serviceLinks = new Map<string, [string | string[], string][]>()
for (const rel of allLinks) {
const mods = Object.keys(rel)
if (mods.length > 2) {
throw new Error(`Only two modules can be linked.`)
}
for (const link of allLinks) {
const service = this.getLinkModuleOrThrow(link)
const { moduleA, moduleB, moduleBKey, primaryKeys } =
this.getLinkDataConfig(link)
const [moduleA, moduleB] = mods
const pk = Object.keys(rel[moduleA])
const moduleAKey = pk.join(",")
const moduleBKey = Object.keys(rel[moduleB]).join(",")
const service = this.getLinkModule(
moduleA,
moduleAKey,
moduleB,
moduleBKey
)
if (!service) {
throw new Error(
linkingErrorMessage({
moduleA,
moduleAKey,
moduleB,
moduleBKey,
type: "dismiss",
})
)
} else if (!serviceLinks.has(service.__definition.key)) {
if (!serviceLinks.has(service.__definition.key)) {
serviceLinks.set(service.__definition.key, [])
}
const pkValue =
pk.length === 1 ? rel[moduleA][pk[0]] : pk.map((k) => rel[moduleA][k])
primaryKeys.length === 1
? link[moduleA][primaryKeys[0]]
: primaryKeys.map((k) => link[moduleA][k])
serviceLinks
.get(service.__definition.key)
?.push([pkValue, rel[moduleB][moduleBKey]])
?.push([pkValue, link[moduleB][moduleBKey]] as any)
}
const promises: Promise<unknown[]>[] = []
for (const [serviceName, links] of serviceLinks) {
const service = this.modulesMap.get(serviceName)!
promises.push(service.dismiss(links))
}
const created = await promiseAll(promises)
return created.flat()
return (await promiseAll(promises)).flat()
}
async delete(
@@ -453,4 +463,45 @@ export class RemoteLink {
): Promise<[CascadeError[] | null, RestoredIds]> {
return await this.executeCascade(removedServices, "restore")
}
async list(
link: LinkDefinition | LinkDefinition[],
options?: { asLinkDefinition?: boolean }
): Promise<(object | LinkDefinition)[]> {
const allLinks = Array.isArray(link) ? link : [link]
const serviceLinks = new Map<string, object[]>()
for (const link of allLinks) {
const service = this.getLinkModuleOrThrow(link)
const { moduleA, moduleB, moduleBKey, primaryKeys } =
this.getLinkDataConfig(link)
if (!serviceLinks.has(service.__definition.key)) {
serviceLinks.set(service.__definition.key, [])
}
serviceLinks.get(service.__definition.key)?.push({
...link[moduleA],
...link[moduleB],
})
}
const promises: Promise<object[]>[] = []
for (const [serviceName, filters] of serviceLinks) {
const service = this.modulesMap.get(serviceName)!
promises.push(
service
.list({ $or: filters })
.then((links: any[]) =>
options?.asLinkDefinition
? convertRecordsToLinkDefinition(links, service)
: links
)
)
}
return (await promiseAll(promises)).flat()
}
}

View File

@@ -0,0 +1,37 @@
import { LoadedModule } from "@medusajs/types"
import { isPresent } from "@medusajs/utils"
import { LinkDefinition } from "../remote-link"
export const convertRecordsToLinkDefinition = (
links: object[],
service: LoadedModule
): LinkDefinition[] => {
const linkRelations = service.__joinerConfig.relationships || []
const linkDataFields = service.__joinerConfig.extraDataFields || []
const results: LinkDefinition[] = []
for (const link of links) {
const result: LinkDefinition = {}
for (const relation of linkRelations) {
result[relation.serviceName] = {
[relation.foreignKey]: link[relation.foreignKey],
}
}
const data: LinkDefinition["data"] = {}
for (const dataField of linkDataFields) {
data[dataField] = link[dataField]
}
if (isPresent(data)) {
result.data = data
}
results.push(result)
}
return results
}

View File

@@ -9,10 +9,10 @@ export type LinkWorkflowInput = {
remove?: string[]
}
export type BatchMethodRequest<TCreate, TUpdate> = {
export type BatchMethodRequest<TCreate, TUpdate, TDelete = string> = {
create?: TCreate[]
update?: TUpdate[]
delete?: string[]
delete?: TDelete[]
}
export type BatchMethodResponse<T> = {
@@ -25,9 +25,10 @@ export type BatchMethodResponse<T> = {
}
}
export type BatchWorkflowInput<TCreate, TUpdate> = BatchMethodRequest<
export type BatchWorkflowInput<
TCreate,
TUpdate
>
TUpdate,
TDelete = string
> = BatchMethodRequest<TCreate, TUpdate, TDelete>
export type BatchWorkflowOutput<T> = BatchMethodResponse<T>

View File

@@ -128,6 +128,29 @@ export type ModulesResponse = {
resolution: string | false
}[]
type ExtraFieldType =
| "date"
| "time"
| "datetime"
| "bigint"
| "blob"
| "uint8array"
| "array"
| "enumArray"
| "enum"
| "json"
| "integer"
| "smallint"
| "tinyint"
| "mediumint"
| "float"
| "double"
| "boolean"
| "decimal"
| "string"
| "uuid"
| "text"
export type ModuleJoinerConfig = Omit<
JoinerServiceConfig,
"serviceName" | "primaryKeys" | "relationships" | "extends"
@@ -164,6 +187,11 @@ export type ModuleJoinerConfig = Omit<
* If true it expands a RemoteQuery property but doesn't create a pivot table
*/
isReadOnlyLink?: boolean
/**
* Fields that will be part of the link record aside from the primary keys that can be updated
* If not explicitly defined, this array will be populated by databaseConfig.extraFields
*/
extraDataFields?: string[]
databaseConfig?: {
/**
* Name of the pivot table. If not provided it is auto generated
@@ -176,28 +204,7 @@ export type ModuleJoinerConfig = Omit<
extraFields?: Record<
string,
{
type:
| "date"
| "time"
| "datetime"
| "bigint"
| "blob"
| "uint8array"
| "array"
| "enumArray"
| "enum"
| "json"
| "integer"
| "smallint"
| "tinyint"
| "mediumint"
| "float"
| "double"
| "boolean"
| "decimal"
| "string"
| "uuid"
| "text"
type: ExtraFieldType
defaultValue?: string
nullable?: boolean
/**

View File

@@ -0,0 +1,66 @@
import { dismissLinksWorkflow, updateLinksWorkflow } from "@medusajs/core-flows"
import { Modules } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../../../../types/routing"
import { refetchVariant } from "../../../../../helpers"
import { AdminUpdateVariantInventoryItemType } from "../../../../../validators"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminUpdateVariantInventoryItemType>,
res: MedusaResponse
) => {
const variantId = req.params.variant_id
const inventoryItemId = req.params.inventory_item_id
await updateLinksWorkflow(req.scope).run({
input: [
{
[Modules.PRODUCT]: { variant_id: variantId },
[Modules.INVENTORY]: { inventory_item_id: inventoryItemId },
data: { required_quantity: req.validatedBody.required_quantity },
},
],
})
const variant = await refetchVariant(
variantId,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ variant })
}
export const DELETE = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const variantId = req.params.variant_id
const inventoryItemId = req.params.inventory_item_id
const {
result: [deleted],
} = await dismissLinksWorkflow(req.scope).run({
input: [
{
[Modules.PRODUCT]: { variant_id: variantId },
[Modules.INVENTORY]: { inventory_item_id: inventoryItemId },
},
],
})
const parent = await refetchVariant(
variantId,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({
id: deleted,
object: "variant-inventory-item-link",
deleted: true,
parent,
})
}

View File

@@ -0,0 +1,35 @@
import { createLinksWorkflow } from "@medusajs/core-flows"
import { Modules } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../../../types/routing"
import { refetchVariant } from "../../../../helpers"
import { AdminCreateVariantInventoryItemType } from "../../../../validators"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminCreateVariantInventoryItemType>,
res: MedusaResponse
) => {
const variantId = req.params.variant_id
await createLinksWorkflow(req.scope).run({
input: [
{
[Modules.PRODUCT]: { variant_id: variantId },
[Modules.INVENTORY]: {
inventory_item_id: req.validatedBody.inventory_item_id,
},
data: { required_quantity: req.validatedBody.required_quantity },
},
],
})
const variant = await refetchVariant(
variantId,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ variant })
}

View File

@@ -0,0 +1,28 @@
import { batchLinksWorkflow } from "@medusajs/core-flows"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../../../types/routing"
import { buildBatchVariantInventoryData } from "../../../../helpers"
import { AdminBatchVariantInventoryItemsType } from "../../../../validators"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminBatchVariantInventoryItemsType>,
res: MedusaResponse
) => {
const { create = [], update = [], delete: toDelete = [] } = req.validatedBody
const { result } = await batchLinksWorkflow(req.scope).run({
input: {
create: buildBatchVariantInventoryData(create),
update: buildBatchVariantInventoryData(update),
delete: buildBatchVariantInventoryData(toDelete),
},
})
res.status(200).json({
created: result.created,
updated: result.updated,
deleted: result.deleted,
})
}

View File

@@ -1,3 +1,4 @@
import { LinkDefinition } from "@medusajs/modules-sdk"
import {
BatchMethodResponse,
MedusaContainer,
@@ -5,10 +6,12 @@ import {
ProductVariantDTO,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
Modules,
promiseAll,
remoteQueryObjectFromString,
ContainerRegistrationKeys,
} from "@medusajs/utils"
import { AdminBatchVariantInventoryItemsType } from "./validators"
const isPricing = (fieldName: string) =>
fieldName.startsWith("variants.prices") ||
@@ -89,6 +92,25 @@ export const refetchProduct = async (
return products[0]
}
export const refetchVariant = async (
variantId: string,
scope: MedusaContainer,
fields: string[]
) => {
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "product_variant",
variables: {
filters: { id: variantId },
},
fields: remapKeysForVariant(fields ?? []),
})
const [variant] = await remoteQuery(queryObject)
return remapVariantResponse(variant)
}
export const refetchBatchProducts = async (
batchResult: BatchMethodResponse<ProductDTO>,
scope: MedusaContainer,
@@ -174,3 +196,31 @@ export const refetchBatchVariants = async (
deleted: batchResult.deleted,
}
}
export const buildBatchVariantInventoryData = (
inputs:
| AdminBatchVariantInventoryItemsType["create"]
| AdminBatchVariantInventoryItemsType["update"]
| AdminBatchVariantInventoryItemsType["delete"]
) => {
const results: LinkDefinition[] = []
for (const input of inputs || []) {
const result: LinkDefinition = {
[Modules.PRODUCT]: { variant_id: input.variant_id },
[Modules.INVENTORY]: {
inventory_item_id: input.inventory_item_id,
},
}
if ("required_quantity" in input) {
result.data = {
required_quantity: input.required_quantity,
}
}
results.push(result)
}
return results
}

View File

@@ -7,20 +7,25 @@ import { createBatchBody } from "../../utils/validators"
import * as QueryConfig from "./query-config"
import { maybeApplyPriceListsFilter } from "./utils"
import {
AdminGetProductsParams,
AdminBatchCreateVariantInventoryItem,
AdminBatchDeleteVariantInventoryItem,
AdminBatchUpdateProduct,
AdminBatchUpdateProductVariant,
AdminBatchUpdateVariantInventoryItem,
AdminCreateProduct,
AdminCreateProductOption,
AdminCreateProductVariant,
AdminCreateVariantInventoryItem,
AdminGetProductOptionParams,
AdminGetProductOptionsParams,
AdminGetProductParams,
AdminGetProductsParams,
AdminGetProductVariantParams,
AdminGetProductVariantsParams,
AdminUpdateProduct,
AdminUpdateProductOption,
AdminGetProductParams,
AdminGetProductVariantsParams,
AdminGetProductVariantParams,
AdminUpdateProductVariant,
AdminGetProductOptionsParams,
AdminGetProductOptionParams,
AdminBatchUpdateProduct,
AdminBatchUpdateProductVariant,
AdminUpdateVariantInventoryItem,
} from "./validators"
export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
@@ -243,4 +248,57 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
// Variant inventory item endpoints
{
method: ["POST"],
matcher: "/admin/products/:id/variants/inventory-items/batch",
middlewares: [
validateAndTransformBody(
createBatchBody(
AdminBatchCreateVariantInventoryItem,
AdminBatchUpdateVariantInventoryItem,
AdminBatchDeleteVariantInventoryItem
)
),
validateAndTransformQuery(
AdminGetProductVariantParams,
QueryConfig.retrieveVariantConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/products/:id/variants/:variant_id/inventory-items",
middlewares: [
validateAndTransformBody(AdminCreateVariantInventoryItem),
validateAndTransformQuery(
AdminGetProductVariantParams,
QueryConfig.retrieveVariantConfig
),
],
},
{
method: ["POST"],
matcher:
"/admin/products/:id/variants/:variant_id/inventory-items/:inventory_item_id",
middlewares: [
validateAndTransformBody(AdminUpdateVariantInventoryItem),
validateAndTransformQuery(
AdminGetProductVariantParams,
QueryConfig.retrieveVariantConfig
),
],
},
{
method: ["DELETE"],
matcher:
"/admin/products/:id/variants/:variant_id/inventory-items/:inventory_item_id",
middlewares: [
validateAndTransformQuery(
AdminGetProductVariantParams,
QueryConfig.retrieveVariantConfig
),
],
},
]

View File

@@ -1,3 +1,4 @@
import { BatchMethodRequest } from "@medusajs/types"
import { ProductStatus } from "@medusajs/utils"
import { z } from "zod"
import { GetProductsParams } from "../../utils/common-validators"
@@ -235,3 +236,56 @@ export const AdminBatchUpdateProduct = AdminUpdateProduct.extend({
// @ValidateNested({ each: true })
// @IsArray()
// categories?: ProductProductCategoryReq[]
export const AdminCreateVariantInventoryItem = z.object({
required_quantity: z.number(),
inventory_item_id: z.string(),
})
export type AdminCreateVariantInventoryItemType = z.infer<
typeof AdminCreateVariantInventoryItem
>
export const AdminUpdateVariantInventoryItem = z.object({
required_quantity: z.number(),
})
export type AdminUpdateVariantInventoryItemType = z.infer<
typeof AdminUpdateVariantInventoryItem
>
export const AdminBatchCreateVariantInventoryItem = z
.object({
required_quantity: z.number(),
inventory_item_id: z.string(),
variant_id: z.string(),
})
.strict()
export type AdminBatchCreateVariantInventoryItemType = z.infer<
typeof AdminBatchCreateVariantInventoryItem
>
export const AdminBatchUpdateVariantInventoryItem = z
.object({
required_quantity: z.number(),
inventory_item_id: z.string(),
variant_id: z.string(),
})
.strict()
export type AdminBatchUpdateVariantInventoryItemType = z.infer<
typeof AdminBatchUpdateVariantInventoryItem
>
export const AdminBatchDeleteVariantInventoryItem = z
.object({
inventory_item_id: z.string(),
variant_id: z.string(),
})
.strict()
export type AdminBatchDeleteVariantInventoryItemType = z.infer<
typeof AdminBatchDeleteVariantInventoryItem
>
export type AdminBatchVariantInventoryItemsType = BatchMethodRequest<
AdminBatchCreateVariantInventoryItemType,
AdminBatchUpdateVariantInventoryItemType,
AdminBatchDeleteVariantInventoryItemType
>

View File

@@ -2,12 +2,13 @@ import { z } from "zod"
export const createBatchBody = (
createValidator: z.ZodType,
updateValidator: z.ZodType
updateValidator: z.ZodType,
deleteValidator: z.ZodType = z.string()
) => {
return z.object({
create: z.array(createValidator).optional(),
update: z.array(updateValidator).optional(),
delete: z.array(z.string()).optional(),
delete: z.array(deleteValidator).optional(),
})
}

View File

@@ -1,8 +1,8 @@
import {
InternalModuleDeclaration,
MedusaModule,
MODULE_RESOURCE_TYPE,
MODULE_SCOPE,
MedusaModule,
ModuleRegistrationName,
} from "@medusajs/modules-sdk"
import {
@@ -16,6 +16,7 @@ import {
ModuleServiceInitializeOptions,
} from "@medusajs/types"
import {
arrayDifference,
ContainerRegistrationKeys,
lowerCaseFirst,
simpleHash,
@@ -50,7 +51,9 @@ export const initialize = async (
)
for (const linkDefinition of allLinksToLoad) {
const definition = JSON.parse(JSON.stringify(linkDefinition))
const definition: ModuleJoinerConfig = JSON.parse(
JSON.stringify(linkDefinition)
)
const [primary, foreign] = definition.relationships ?? []
@@ -65,6 +68,24 @@ export const initialize = async (
throw new Error(`Foreign key cannot be a composed key.`)
}
if (Array.isArray(definition.extraDataFields)) {
const extraDataFields = definition.extraDataFields
const definedDbFields = Object.keys(
definition.databaseConfig?.extraFields || {}
)
const difference = arrayDifference(extraDataFields, definedDbFields)
if (difference.length) {
throw new Error(
`extraDataFields (fieldNames: ${difference.join(
","
)}) need to be configured under databaseConfig (serviceName: ${
definition.serviceName
}).`
)
}
}
const serviceKey = !definition.isReadOnlyLink
? lowerCaseFirst(
definition.serviceName ??
@@ -112,7 +133,10 @@ export const initialize = async (
logger,
})
definition.alias ??= []
if (!Array.isArray(definition.alias)) {
definition.alias = definition.alias ? [definition.alias] : []
}
for (const alias of definition.alias) {
alias.args ??= {}

View File

@@ -1,11 +1,23 @@
import { Constructor, ILinkModule, ModuleJoinerConfig } from "@medusajs/types"
import { isDefined } from "@medusajs/utils"
import { LinkModuleService } from "@services"
export function getModuleService(
joinerConfig: ModuleJoinerConfig
): Constructor<ILinkModule> {
const joinerConfig_ = JSON.parse(JSON.stringify(joinerConfig))
const databaseConfig = joinerConfig_.databaseConfig
delete joinerConfig_.databaseConfig
// If extraDataFields is not defined, pick the fields to populate and validate from the
// database config if any fields are provided.
if (!isDefined(joinerConfig_.extraDataFields)) {
joinerConfig_.extraDataFields = Object.keys(
databaseConfig.extraFields || {}
)
}
return class LinkService extends LinkModuleService<unknown> {
override __joinerConfig(): ModuleJoinerConfig {
return joinerConfig_ as ModuleJoinerConfig