From ecfbfcc707e3752cdb931067a5340d8ff6798224 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Mon, 3 Jun 2024 20:23:29 +0200 Subject: [PATCH] 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 ``` --- .../__tests__/product/admin/product.spec.ts | 300 ++++++++++++++++- .../__tests__/common/workflows.spec.ts | 313 ++++++++++++++++++ packages/core/core-flows/src/common/index.ts | 8 +- .../src/common/steps/create-remote-links.ts | 7 +- .../src/common/steps/dismiss-remote-links.ts | 18 +- .../src/common/steps/update-remote-links.ts | 48 +++ .../src/common/workflows/batch-links.ts | 32 ++ .../src/common/workflows/create-links.ts | 11 + .../src/common/workflows/dismiss-links.ts | 11 + .../src/common/workflows/update-links.ts | 11 + packages/core/modules-sdk/src/remote-link.ts | 191 +++++++---- .../utils/convert-data-to-link-definition.ts | 37 +++ packages/core/types/src/common/batch.ts | 11 +- packages/core/types/src/modules-sdk/index.ts | 51 +-- .../[inventory_item_id]/route.ts | 66 ++++ .../[variant_id]/inventory-items/route.ts | 35 ++ .../variants/inventory-items/batch/route.ts | 28 ++ .../medusa/src/api/admin/products/helpers.ts | 52 ++- .../src/api/admin/products/middlewares.ts | 74 ++++- .../src/api/admin/products/validators.ts | 54 +++ packages/medusa/src/api/utils/validators.ts | 5 +- .../link-modules/src/initialize/index.ts | 30 +- .../src/services/dynamic-service-class.ts | 12 + 23 files changed, 1279 insertions(+), 126 deletions(-) create mode 100644 integration-tests/modules/__tests__/common/workflows.spec.ts create mode 100644 packages/core/core-flows/src/common/steps/update-remote-links.ts create mode 100644 packages/core/core-flows/src/common/workflows/batch-links.ts create mode 100644 packages/core/core-flows/src/common/workflows/create-links.ts create mode 100644 packages/core/core-flows/src/common/workflows/dismiss-links.ts create mode 100644 packages/core/core-flows/src/common/workflows/update-links.ts create mode 100644 packages/core/modules-sdk/src/utils/convert-data-to-link-definition.ts create mode 100644 packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/inventory-items/[inventory_item_id]/route.ts create mode 100644 packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/inventory-items/route.ts create mode 100644 packages/medusa/src/api/admin/products/[id]/variants/inventory-items/batch/route.ts diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 40b833ab52..7c50596a1d 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -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) + }) + }) }) }, }) diff --git a/integration-tests/modules/__tests__/common/workflows.spec.ts b/integration-tests/modules/__tests__/common/workflows.spec.ts new file mode 100644 index 0000000000..dd8fd9bcb0 --- /dev/null +++ b/integration-tests/modules/__tests__/common/workflows.spec.ts @@ -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([]) + }) + }) + }) + }) + }, +}) diff --git a/packages/core/core-flows/src/common/index.ts b/packages/core/core-flows/src/common/index.ts index 08541ae8c3..b148f8a412 100644 --- a/packages/core/core-flows/src/common/index.ts +++ b/packages/core/core-flows/src/common/index.ts @@ -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" diff --git a/packages/core/core-flows/src/common/steps/create-remote-links.ts b/packages/core/core-flows/src/common/steps/create-remote-links.ts index b8f3281d59..0483c9a435 100644 --- a/packages/core/core-flows/src/common/steps/create-remote-links.ts +++ b/packages/core/core-flows/src/common/steps/create-remote-links.ts @@ -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( ContainerRegistrationKeys.REMOTE_LINK ) diff --git a/packages/core/core-flows/src/common/steps/dismiss-remote-links.ts b/packages/core/core-flows/src/common/steps/dismiss-remote-links.ts index 5efd4de09e..7650a4b7d9 100644 --- a/packages/core/core-flows/src/common/steps/dismiss-remote-links.ts +++ b/packages/core/core-flows/src/common/steps/dismiss-remote-links.ts @@ -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( 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( ContainerRegistrationKeys.REMOTE_LINK ) - await link.create(dismissdLinks) + + await link.create(dataBeforeDismiss) } ) diff --git a/packages/core/core-flows/src/common/steps/update-remote-links.ts b/packages/core/core-flows/src/common/steps/update-remote-links.ts new file mode 100644 index 0000000000..1354a30b8a --- /dev/null +++ b/packages/core/core-flows/src/common/steps/update-remote-links.ts @@ -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( + 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( + ContainerRegistrationKeys.REMOTE_LINK + ) + + await link.create(dataBeforeUpdate) + } +) diff --git a/packages/core/core-flows/src/common/workflows/batch-links.ts b/packages/core/core-flows/src/common/workflows/batch-links.ts new file mode 100644 index 0000000000..e51faaff1c --- /dev/null +++ b/packages/core/core-flows/src/common/workflows/batch-links.ts @@ -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 + > + ) => { + const [created, updated, deleted] = parallelize( + createRemoteLinkStep(input.create || []), + updateRemoteLinksStep(input.update || []), + dismissRemoteLinkStep(input.delete || []) + ) + + return { + created, + updated, + deleted, + } + } +) diff --git a/packages/core/core-flows/src/common/workflows/create-links.ts b/packages/core/core-flows/src/common/workflows/create-links.ts new file mode 100644 index 0000000000..d94b4df641 --- /dev/null +++ b/packages/core/core-flows/src/common/workflows/create-links.ts @@ -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) => { + return createRemoteLinkStep(input) + } +) diff --git a/packages/core/core-flows/src/common/workflows/dismiss-links.ts b/packages/core/core-flows/src/common/workflows/dismiss-links.ts new file mode 100644 index 0000000000..14af112e82 --- /dev/null +++ b/packages/core/core-flows/src/common/workflows/dismiss-links.ts @@ -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) => { + return dismissRemoteLinkStep(input) + } +) diff --git a/packages/core/core-flows/src/common/workflows/update-links.ts b/packages/core/core-flows/src/common/workflows/update-links.ts new file mode 100644 index 0000000000..566c9755f0 --- /dev/null +++ b/packages/core/core-flows/src/common/workflows/update-links.ts @@ -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) => { + return updateRemoteLinksStep(input) + } +) diff --git a/packages/core/modules-sdk/src/remote-link.ts b/packages/core/modules-sdk/src/remote-link.ts index 2eb5209660..8d77e06f63 100644 --- a/packages/core/modules-sdk/src/remote-link.ts +++ b/packages/core/modules-sdk/src/remote-link.ts @@ -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 @@ -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 = new Map() private relationsPairs: Map = 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 { const allLinks = Array.isArray(link) ? link : [link] const serviceLinks = new Map< @@ -332,114 +384,72 @@ export class RemoteLink { [string | string[], string, Record?][] >() - 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[] = [] + 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 { const allLinks = Array.isArray(link) ? link : [link] const serviceLinks = new Map() - 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[] = [] + 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() + + 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[] = [] + + 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() + } } diff --git a/packages/core/modules-sdk/src/utils/convert-data-to-link-definition.ts b/packages/core/modules-sdk/src/utils/convert-data-to-link-definition.ts new file mode 100644 index 0000000000..c197efcca7 --- /dev/null +++ b/packages/core/modules-sdk/src/utils/convert-data-to-link-definition.ts @@ -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 +} diff --git a/packages/core/types/src/common/batch.ts b/packages/core/types/src/common/batch.ts index fb27e73067..23c1eb6393 100644 --- a/packages/core/types/src/common/batch.ts +++ b/packages/core/types/src/common/batch.ts @@ -9,10 +9,10 @@ export type LinkWorkflowInput = { remove?: string[] } -export type BatchMethodRequest = { +export type BatchMethodRequest = { create?: TCreate[] update?: TUpdate[] - delete?: string[] + delete?: TDelete[] } export type BatchMethodResponse = { @@ -25,9 +25,10 @@ export type BatchMethodResponse = { } } -export type BatchWorkflowInput = BatchMethodRequest< +export type BatchWorkflowInput< TCreate, - TUpdate -> + TUpdate, + TDelete = string +> = BatchMethodRequest export type BatchWorkflowOutput = BatchMethodResponse diff --git a/packages/core/types/src/modules-sdk/index.ts b/packages/core/types/src/modules-sdk/index.ts index 5c7eb274b9..d81041da82 100644 --- a/packages/core/types/src/modules-sdk/index.ts +++ b/packages/core/types/src/modules-sdk/index.ts @@ -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 /** diff --git a/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/inventory-items/[inventory_item_id]/route.ts b/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/inventory-items/[inventory_item_id]/route.ts new file mode 100644 index 0000000000..c21d86b1db --- /dev/null +++ b/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/inventory-items/[inventory_item_id]/route.ts @@ -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, + 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, + }) +} diff --git a/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/inventory-items/route.ts b/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/inventory-items/route.ts new file mode 100644 index 0000000000..4e6c11a1cb --- /dev/null +++ b/packages/medusa/src/api/admin/products/[id]/variants/[variant_id]/inventory-items/route.ts @@ -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, + 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 }) +} diff --git a/packages/medusa/src/api/admin/products/[id]/variants/inventory-items/batch/route.ts b/packages/medusa/src/api/admin/products/[id]/variants/inventory-items/batch/route.ts new file mode 100644 index 0000000000..76fba829a5 --- /dev/null +++ b/packages/medusa/src/api/admin/products/[id]/variants/inventory-items/batch/route.ts @@ -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, + 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, + }) +} diff --git a/packages/medusa/src/api/admin/products/helpers.ts b/packages/medusa/src/api/admin/products/helpers.ts index 634bba7450..2519707ecc 100644 --- a/packages/medusa/src/api/admin/products/helpers.ts +++ b/packages/medusa/src/api/admin/products/helpers.ts @@ -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, 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 +} diff --git a/packages/medusa/src/api/admin/products/middlewares.ts b/packages/medusa/src/api/admin/products/middlewares.ts index 2f34cd941d..e79c6210e2 100644 --- a/packages/medusa/src/api/admin/products/middlewares.ts +++ b/packages/medusa/src/api/admin/products/middlewares.ts @@ -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 + ), + ], + }, ] diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index 75c623c87c..80dfd28bda 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -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 +> diff --git a/packages/medusa/src/api/utils/validators.ts b/packages/medusa/src/api/utils/validators.ts index 25f2265ed6..11ffe6e271 100644 --- a/packages/medusa/src/api/utils/validators.ts +++ b/packages/medusa/src/api/utils/validators.ts @@ -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(), }) } diff --git a/packages/modules/link-modules/src/initialize/index.ts b/packages/modules/link-modules/src/initialize/index.ts index 07f185d546..63c500bb30 100644 --- a/packages/modules/link-modules/src/initialize/index.ts +++ b/packages/modules/link-modules/src/initialize/index.ts @@ -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 ??= {} diff --git a/packages/modules/link-modules/src/services/dynamic-service-class.ts b/packages/modules/link-modules/src/services/dynamic-service-class.ts index a06db9e8a3..27c867c8c4 100644 --- a/packages/modules/link-modules/src/services/dynamic-service-class.ts +++ b/packages/modules/link-modules/src/services/dynamic-service-class.ts @@ -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 { 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 { override __joinerConfig(): ModuleJoinerConfig { return joinerConfig_ as ModuleJoinerConfig