From aba9ded2a35e47982b46a160cd29c0122ed2c5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Thu, 19 Oct 2023 14:02:40 +0200 Subject: [PATCH] feat(workflows): update product workflow (#4982) **What** - added "update product" workflow Co-authored-by: Riqwan Thamir <5105988+riqwan@users.noreply.github.com> --- .../plugins/__tests__/product/admin/index.ts | 224 ++++++++++- .../workflows/product/update-product.ts | 110 ++++++ integration-tests/plugins/medusa-config.js | 1 + .../1697708391459-scope-level-unique.ts | 19 + .../inventory/src/services/inventory-item.ts | 24 ++ .../inventory/src/services/inventory-level.ts | 31 +- packages/inventory/src/services/inventory.ts | 21 + .../routes/admin/products/update-product.ts | 280 +++++++++----- .../src/services/product-module-service.ts | 29 +- .../product/src/services/product-variant.ts | 20 + packages/types/src/inventory/service.ts | 5 + packages/types/src/product/service.ts | 6 + .../src/workflow/product/create-products.ts | 8 +- packages/types/src/workflow/product/index.ts | 1 + .../src/workflow/product/update-products.ts | 88 +++++ .../workflows/src/definition/product/index.ts | 1 + .../src/definition/product/update-products.ts | 364 ++++++++++++++++++ packages/workflows/src/definitions.ts | 1 + .../inventory/attach-inventory-items.ts | 6 +- .../inventory/create-inventory-items.ts | 4 +- .../inventory/detach-inventory-items.ts | 8 +- .../workflows/src/handlers/inventory/index.ts | 1 + .../inventory/remove-inventory-items.ts | 6 +- .../inventory/restore-inventory-items.ts | 35 ++ .../handlers/middlewares/extract-variants.ts | 28 ++ .../src/handlers/middlewares/index.ts | 5 + .../src/handlers/middlewares/map-data.ts | 16 + ...pdate-products-extract-created-variants.ts | 40 ++ ...pdate-products-extract-deleted-variants.ts | 43 +++ .../use-variants-inventory-items.ts | 46 +++ .../workflows/src/handlers/product/index.ts | 3 + .../product/revert-update-products.ts | 38 ++ .../product/update-products-prepare-data.ts | 81 ++++ .../src/handlers/product/update-products.ts | 44 +++ 34 files changed, 1511 insertions(+), 126 deletions(-) create mode 100644 integration-tests/plugins/__tests__/workflows/product/update-product.ts create mode 100644 packages/inventory/src/migrations/schema-migrations/1697708391459-scope-level-unique.ts create mode 100644 packages/types/src/workflow/product/update-products.ts create mode 100644 packages/workflows/src/definition/product/update-products.ts create mode 100644 packages/workflows/src/handlers/inventory/restore-inventory-items.ts create mode 100644 packages/workflows/src/handlers/middlewares/extract-variants.ts create mode 100644 packages/workflows/src/handlers/middlewares/map-data.ts create mode 100644 packages/workflows/src/handlers/middlewares/update-products-extract-created-variants.ts create mode 100644 packages/workflows/src/handlers/middlewares/update-products-extract-deleted-variants.ts create mode 100644 packages/workflows/src/handlers/middlewares/use-variants-inventory-items.ts create mode 100644 packages/workflows/src/handlers/product/revert-update-products.ts create mode 100644 packages/workflows/src/handlers/product/update-products-prepare-data.ts create mode 100644 packages/workflows/src/handlers/product/update-products.ts diff --git a/integration-tests/plugins/__tests__/product/admin/index.ts b/integration-tests/plugins/__tests__/product/admin/index.ts index 9b59d897a2..53567b11c5 100644 --- a/integration-tests/plugins/__tests__/product/admin/index.ts +++ b/integration-tests/plugins/__tests__/product/admin/index.ts @@ -9,7 +9,10 @@ import productSeeder from "../../../../helpers/product-seeder" import { Modules, ModulesDefinition } from "@medusajs/modules-sdk" import { Workflows } from "@medusajs/workflows" import { AxiosInstance } from "axios" -import { simpleSalesChannelFactory } from "../../../../factories" +import { + simpleProductFactory, + simpleSalesChannelFactory, +} from "../../../../factories" jest.setTimeout(5000000) @@ -428,4 +431,223 @@ describe("/admin/products", () => { ) }) }) + + describe("POST /admin/products/:id", () => { + const toUpdateWithSalesChannels = "to-update-with-sales-channels" + const toUpdateWithVariants = "to-update-with-variants" + const toUpdate = "to-update" + + beforeEach(async () => { + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + + await simpleSalesChannelFactory(dbConnection, { + name: "Default channel", + id: "default-channel", + is_default: true, + }) + + await simpleSalesChannelFactory(dbConnection, { + name: "Channel 3", + id: "channel-3", + is_default: true, + }) + + await simpleProductFactory(dbConnection, { + title: "To update product", + id: toUpdate, + }) + + await simpleProductFactory(dbConnection, { + title: "To update product with channels", + id: toUpdateWithSalesChannels, + sales_channels: [ + { name: "channel 1", id: "channel-1" }, + { name: "channel 2", id: "channel-2" }, + ], + }) + + await simpleSalesChannelFactory(dbConnection, { + name: "To be added", + id: "to-be-added", + }) + + await simpleProductFactory(dbConnection, { + title: "To update product with variants", + id: toUpdateWithVariants, + variants: [ + { + id: "variant-1", + title: "Variant 1", + }, + { + id: "variant-2", + title: "Variant 2", + }, + ], + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should do a basic product update", async () => { + const api = useApi()! as AxiosInstance + + const payload = { + title: "New title", + description: "test-product-description", + } + + const response = await api + .post(`/admin/products/${toUpdate}`, payload, adminHeaders) + .catch((err) => { + console.log(err) + }) + + expect(response?.status).toEqual(200) + expect(response?.data.product).toEqual( + expect.objectContaining({ + id: toUpdate, + title: "New title", + description: "test-product-description", + }) + ) + }) + + it("should update product and also update a variant and create a variant", async () => { + const api = useApi()! as AxiosInstance + + const payload = { + title: "New title", + description: "test-product-description", + variants: [ + { + id: "variant-1", + title: "Variant 1 updated", + }, + { + title: "Variant 3", + }, + ], + } + + const response = await api + .post(`/admin/products/${toUpdateWithVariants}`, payload, adminHeaders) + .catch((err) => { + console.log(err) + }) + + expect(response?.status).toEqual(200) + expect(response?.data.product).toEqual( + expect.objectContaining({ + id: toUpdateWithVariants, + title: "New title", + description: "test-product-description", + variants: expect.arrayContaining([ + expect.objectContaining({ + id: "variant-1", + title: "Variant 1 updated", + }), + expect.objectContaining({ + title: "Variant 3", + }), + ]), + }) + ) + }) + + it("should update product's sales channels", async () => { + const api = useApi()! as AxiosInstance + + const payload = { + title: "New title", + description: "test-product-description", + sales_channels: [{ id: "channel-2" }, { id: "channel-3" }], + } + + const response = await api + .post( + `/admin/products/${toUpdateWithSalesChannels}`, + payload, + adminHeaders + ) + .catch((err) => { + console.log(err) + }) + + expect(response?.status).toEqual(200) + expect(response?.data.product).toEqual( + expect.objectContaining({ + id: toUpdateWithSalesChannels, + sales_channels: [ + expect.objectContaining({ id: "channel-2" }), + expect.objectContaining({ id: "channel-3" }), + ], + }) + ) + }) + + it("should update inventory when variants are updated", async () => { + const api = useApi()! as AxiosInstance + + const variantInventoryService = medusaContainer.resolve( + "productVariantInventoryService" + ) + + const payload = { + title: "New title", + description: "test-product-description", + variants: [ + { + id: "variant-1", + title: "Variant 1 updated", + }, + { + title: "Variant 3", + }, + ], + } + + const response = await api + .post(`/admin/products/${toUpdateWithVariants}`, payload, adminHeaders) + .catch((err) => { + console.log(err) + }) + + let inventory = await variantInventoryService.listInventoryItemsByVariant( + "variant-2" + ) + + expect(response?.status).toEqual(200) + expect(response?.data.product).toEqual( + expect.objectContaining({ + id: toUpdateWithVariants, + title: "New title", + description: "test-product-description", + variants: expect.arrayContaining([ + expect.objectContaining({ + id: "variant-1", + title: "Variant 1 updated", + }), + expect.objectContaining({ + title: "Variant 3", + }), + ]), + }) + ) + + expect(inventory).toEqual([]) // no inventory items for removed variant + + inventory = await variantInventoryService.listInventoryItemsByVariant( + response?.data.product.variants.find((v) => v.title === "Variant 3").id + ) + + expect(inventory).toEqual([ + expect.objectContaining({ id: expect.any(String) }), + ]) + }) + }) }) diff --git a/integration-tests/plugins/__tests__/workflows/product/update-product.ts b/integration-tests/plugins/__tests__/workflows/product/update-product.ts new file mode 100644 index 0000000000..9f08dcbdd0 --- /dev/null +++ b/integration-tests/plugins/__tests__/workflows/product/update-product.ts @@ -0,0 +1,110 @@ +import { + Handlers, + pipe, + updateProducts, + UpdateProductsActions, +} from "@medusajs/workflows" +import { WorkflowTypes } from "@medusajs/types" +import path from "path" + +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import { bootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { simpleProductFactory } from "../../../../factories" + +describe("UpdateProduct workflow", function () { + let medusaProcess + let dbConnection + let medusaContainer + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd } as any) + const { container } = await bootstrapApp({ cwd }) + medusaContainer = container + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + beforeEach(async () => { + await simpleProductFactory(dbConnection, { + title: "Original title", + id: "to-update", + variants: [{ id: "original-variant" }], + }) + }) + + it("should compensate all the invoke if something fails", async () => { + const workflow = updateProducts(medusaContainer) + + workflow.appendAction( + "fail_step", + UpdateProductsActions.removeInventoryItems, + { + invoke: pipe({}, async function failStep() { + throw new Error(`Failed to update products`) + }), + }, + { + noCompensation: true, + } + ) + + const input: WorkflowTypes.ProductWorkflow.UpdateProductsWorkflowInputDTO = + { + products: [ + { + id: "to-update", + title: "Updated title", + variants: [ + { + title: "Should be deleted with revert variant", + }, + ], + }, + ], + } + + const manager = medusaContainer.resolve("manager") + const context = { + manager, + } + + const { errors, transaction } = await workflow.run({ + input, + context, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "fail_step", + handlerType: "invoke", + error: new Error(`Failed to update products`), + }, + ]) + + expect(transaction.getState()).toEqual("reverted") + + let [product] = await Handlers.ProductHandlers.listProducts({ + container: medusaContainer, + context, + data: { + ids: ["to-update"], + config: { listConfig: { relations: ["variants"] } }, + }, + } as any) + + expect(product).toEqual( + expect.objectContaining({ + title: "Original title", + id: "to-update", + variants: [expect.objectContaining({ id: "original-variant" })], + }) + ) + }) +}) diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 915999cf10..7f76569523 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -35,6 +35,7 @@ module.exports = { featureFlags: { workflows: { [Workflows.CreateProducts]: true, + [Workflows.UpdateProducts]: true, [Workflows.CreateCart]: true, }, }, diff --git a/packages/inventory/src/migrations/schema-migrations/1697708391459-scope-level-unique.ts b/packages/inventory/src/migrations/schema-migrations/1697708391459-scope-level-unique.ts new file mode 100644 index 0000000000..fdc6167319 --- /dev/null +++ b/packages/inventory/src/migrations/schema-migrations/1697708391459-scope-level-unique.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class ScopeLevelUnique1697708391459 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "UQ_inventory_level_inventory_item_id_location_id"` + ) + + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_inventory_level_inventory_item_id_location_id" ON "inventory_level" ("inventory_item_id", "location_id") WHERE deleted_at IS NULL; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_inventory_level_inventory_item_id_location_id" ON "inventory_level" ("inventory_item_id", "location_id"); + `) + } +} diff --git a/packages/inventory/src/services/inventory-item.ts b/packages/inventory/src/services/inventory-item.ts index 6c58f169df..7cc3ecba67 100644 --- a/packages/inventory/src/services/inventory-item.ts +++ b/packages/inventory/src/services/inventory-item.ts @@ -27,6 +27,7 @@ export default class InventoryItemService { CREATED: "inventory-item.created", UPDATED: "inventory-item.updated", DELETED: "inventory-item.deleted", + RESTORED: "inventory-item.restored", } protected readonly manager_: EntityManager @@ -213,4 +214,27 @@ export default class InventoryItemService { ids: inventoryItemId, }) } + + /** + * @param inventoryItemId - The id of the inventory item to restore. + * @param context + */ + @InjectEntityManager() + async restore( + inventoryItemId: string | string[], + @MedusaContext() context: SharedContext = {} + ): Promise { + const manager = context.transactionManager! + const itemRepository = manager.getRepository(InventoryItem) + + const ids = Array.isArray(inventoryItemId) + ? inventoryItemId + : [inventoryItemId] + + await itemRepository.restore({ id: In(ids) }) + + await this.eventBusService_?.emit?.(InventoryItemService.Events.RESTORED, { + ids: inventoryItemId, + }) + } } diff --git a/packages/inventory/src/services/inventory-level.ts b/packages/inventory/src/services/inventory-level.ts index b7a21219d1..c3c03fa326 100644 --- a/packages/inventory/src/services/inventory-level.ts +++ b/packages/inventory/src/services/inventory-level.ts @@ -25,6 +25,7 @@ export default class InventoryLevelService { CREATED: "inventory-level.created", UPDATED: "inventory-level.updated", DELETED: "inventory-level.deleted", + RESTORED: "inventory-level.restored", } protected readonly manager_: EntityManager @@ -231,13 +232,37 @@ export default class InventoryLevelService { const manager = context.transactionManager! const levelRepository = manager.getRepository(InventoryLevel) - await levelRepository.delete({ inventory_item_id: In(ids) }) + await levelRepository.softDelete({ inventory_item_id: In(ids) }) await this.eventBusService_?.emit?.(InventoryLevelService.Events.DELETED, { inventory_item_id: inventoryItemId, }) } + /** + * Restores inventory levels by inventory Item ID. + * @param inventoryItemId - The ID or IDs of the inventory item to restore inventory levels for. + * @param context + */ + @InjectEntityManager() + async restoreByInventoryItemId( + inventoryItemId: string | string[], + @MedusaContext() context: SharedContext = {} + ): Promise { + const ids = Array.isArray(inventoryItemId) + ? inventoryItemId + : [inventoryItemId] + + const manager = context.transactionManager! + const levelRepository = manager.getRepository(InventoryLevel) + + await levelRepository.restore({ inventory_item_id: In(ids) }) + + await this.eventBusService_?.emit?.(InventoryLevelService.Events.RESTORED, { + inventory_item_id: inventoryItemId, + }) + } + /** * Deletes an inventory level by ID. * @param inventoryLevelId - The ID or IDs of the inventory level to delete. @@ -255,7 +280,7 @@ export default class InventoryLevelService { const manager = context.transactionManager! const levelRepository = manager.getRepository(InventoryLevel) - await levelRepository.delete({ id: In(ids) }) + await levelRepository.softDelete({ id: In(ids) }) await this.eventBusService_?.emit?.(InventoryLevelService.Events.DELETED, { ids: inventoryLevelId, @@ -277,7 +302,7 @@ export default class InventoryLevelService { const ids = Array.isArray(locationId) ? locationId : [locationId] - await levelRepository.delete({ location_id: In(ids) }) + await levelRepository.softDelete({ location_id: In(ids) }) await this.eventBusService_?.emit?.(InventoryLevelService.Events.DELETED, { location_ids: ids, diff --git a/packages/inventory/src/services/inventory.ts b/packages/inventory/src/services/inventory.ts index ae5ca91b3f..93360a9674 100644 --- a/packages/inventory/src/services/inventory.ts +++ b/packages/inventory/src/services/inventory.ts @@ -376,6 +376,27 @@ export default class InventoryService implements IInventoryService { return await this.inventoryItemService_.delete(inventoryItemId, context) } + /** + * Restore an inventory item and levels + * @param inventoryItemId - the id of the inventory item to delete + * @param context + */ + @InjectEntityManager( + (target) => + target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED + ) + async restoreInventoryItem( + inventoryItemId: string | string[], + @MedusaContext() context: SharedContext = {} + ): Promise { + await this.inventoryLevelService_.restoreByInventoryItemId( + inventoryItemId, + context + ) + + return await this.inventoryItemService_.restore(inventoryItemId, context) + } + @InjectEntityManager( (target) => target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED diff --git a/packages/medusa/src/api/routes/admin/products/update-product.ts b/packages/medusa/src/api/routes/admin/products/update-product.ts index 3e53973e8e..b0a7f992ac 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.ts +++ b/packages/medusa/src/api/routes/admin/products/update-product.ts @@ -1,3 +1,7 @@ +import { DistributedTransaction } from "@medusajs/orchestration" +import { FlagRouter, MedusaError } from "@medusajs/utils" +import { Workflows, updateProducts } from "@medusajs/workflows" +import { Type } from "class-transformer" import { IsArray, IsBoolean, @@ -11,7 +15,13 @@ import { ValidateIf, ValidateNested, } from "class-validator" -import { defaultAdminProductFields, defaultAdminProductRelations } from "." +import { EntityManager } from "typeorm" + +import { + defaultAdminProductFields, + defaultAdminProductRelations, + defaultAdminProductRemoteQueryObject, +} from "." import { ProductStatus, ProductVariant } from "../../../../models" import { PricingService, @@ -35,15 +45,13 @@ import { revertVariantTransaction, } from "./transaction/create-product-variant" -import { DistributedTransaction } from "@medusajs/orchestration" -import { IInventoryService } from "@medusajs/types" -import { MedusaError } from "@medusajs/utils" -import { Type } from "class-transformer" -import { EntityManager } from "typeorm" +import { IInventoryService, WorkflowTypes } from "@medusajs/types" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import { ProductVariantRepository } from "../../../../repositories/product-variant" import { Logger } from "../../../../types/global" import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" + +import IsolateProductDomainFeatureFlag from "../../../../loaders/feature-flags/isolate-product-domain" import { validator } from "../../../../utils/validator" /** @@ -129,134 +137,192 @@ export default async (req, res) => { req.scope.resolve("inventoryService") const manager: EntityManager = req.scope.resolve("manager") - await manager.transaction(async (transactionManager) => { - const productServiceTx = productService.withTransaction(transactionManager) - const { variants } = validated - delete validated.variants + const productModuleService = req.scope.resolve("productModuleService") - const product = await productServiceTx.update(id, validated) + const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter") + const isWorkflowEnabled = featureFlagRouter.isFeatureEnabled({ + workflows: Workflows.UpdateProducts, + }) - if (!variants) { - return + if (isWorkflowEnabled && !productModuleService) { + logger.warn( + `Cannot run ${Workflows.UpdateProducts} workflow without '@medusajs/product' installed` + ) + } + + if (isWorkflowEnabled && !!productModuleService) { + const updateProductWorkflow = updateProducts(req.scope) + + const input = { + products: [ + { id, ...validated }, + ] as WorkflowTypes.ProductWorkflow.UpdateProductInputDTO[], } - const variantRepo = manager.withRepository(productVariantRepo) - const productVariants = await productVariantService - .withTransaction(transactionManager) - .list( - { product_id: id }, - { - select: variantRepo.metadata.columns.map( - (c) => c.propertyName - ) as (keyof ProductVariant)[], + const { result } = await updateProductWorkflow.run({ + input, + context: { + manager: manager, + }, + }) + } else { + await manager.transaction(async (transactionManager) => { + const productServiceTx = + productService.withTransaction(transactionManager) + + const { variants } = validated + delete validated.variants + + const product = await productServiceTx.update(id, validated) + + if (!variants) { + return + } + + const variantRepo = manager.withRepository(productVariantRepo) + const productVariants = await productVariantService + .withTransaction(transactionManager) + .list( + { product_id: id }, + { + select: variantRepo.metadata.columns.map( + (c) => c.propertyName + ) as (keyof ProductVariant)[], + } + ) + + const productVariantMap = new Map(productVariants.map((v) => [v.id, v])) + const variantWithIdSet = new Set() + + const variantIdsNotBelongingToProduct: string[] = [] + const variantsToUpdate: { + variant: ProductVariant + updateData: UpdateProductVariantInput + }[] = [] + const variantsToCreate: ProductVariantReq[] = [] + + // Preparing the data step + for (const [variantRank, variant] of variants.entries()) { + if (!variant.id) { + Object.assign(variant, { + variant_rank: variantRank, + options: variant.options || [], + prices: variant.prices || [], + }) + variantsToCreate.push(variant) + continue } - ) - const productVariantMap = new Map(productVariants.map((v) => [v.id, v])) - const variantWithIdSet = new Set() + // Will be used to find the variants that should be removed during the next steps + variantWithIdSet.add(variant.id) - const variantIdsNotBelongingToProduct: string[] = [] - const variantsToUpdate: { - variant: ProductVariant - updateData: UpdateProductVariantInput - }[] = [] - const variantsToCreate: ProductVariantReq[] = [] + if (!productVariantMap.has(variant.id)) { + variantIdsNotBelongingToProduct.push(variant.id) + continue + } - // Preparing the data step - for (const [variantRank, variant] of variants.entries()) { - if (!variant.id) { + const productVariant = productVariantMap.get(variant.id)! Object.assign(variant, { variant_rank: variantRank, - options: variant.options || [], - prices: variant.prices || [], + product_id: productVariant.product_id, }) - variantsToCreate.push(variant) - continue + variantsToUpdate.push({ variant: productVariant, updateData: variant }) } - // Will be used to find the variants that should be removed during the next steps - variantWithIdSet.add(variant.id) - - if (!productVariantMap.has(variant.id)) { - variantIdsNotBelongingToProduct.push(variant.id) - continue + if (variantIdsNotBelongingToProduct.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Variants with id: ${variantIdsNotBelongingToProduct.join( + ", " + )} are not associated with this product` + ) } - const productVariant = productVariantMap.get(variant.id)! - Object.assign(variant, { - variant_rank: variantRank, - product_id: productVariant.product_id, - }) - variantsToUpdate.push({ variant: productVariant, updateData: variant }) - } + const allVariantTransactions: DistributedTransaction[] = [] + const transactionDependencies = { + manager: transactionManager, + inventoryService, + productVariantInventoryService, + productVariantService, + } - if (variantIdsNotBelongingToProduct.length) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Variants with id: ${variantIdsNotBelongingToProduct.join( - ", " - )} are not associated with this product` + const productVariantServiceTx = + productVariantService.withTransaction(transactionManager) + + // Delete the variant that does not exist anymore from the provided variants + const variantIdsToDelete = [...productVariantMap.keys()].filter( + (variantId) => !variantWithIdSet.has(variantId) ) - } - const allVariantTransactions: DistributedTransaction[] = [] - const transactionDependencies = { - manager: transactionManager, - inventoryService, - productVariantInventoryService, - productVariantService, - } - - const productVariantServiceTx = - productVariantService.withTransaction(transactionManager) - - // Delete the variant that does not exist anymore from the provided variants - const variantIdsToDelete = [...productVariantMap.keys()].filter( - (variantId) => !variantWithIdSet.has(variantId) - ) - - if (variantIdsToDelete) { - await productVariantServiceTx.delete(variantIdsToDelete) - } - - if (variantsToUpdate.length) { - await productVariantServiceTx.update(variantsToUpdate) - } - - if (variantsToCreate.length) { - try { - const varTransaction = await createVariantsTransaction( - transactionDependencies, - product.id, - variantsToCreate as CreateProductVariantInput[] - ) - allVariantTransactions.push(varTransaction) - } catch (e) { - await Promise.all( - allVariantTransactions.map(async (transaction) => { - await revertVariantTransaction( - transactionDependencies, - transaction - ).catch(() => logger.warn("Transaction couldn't be reverted.")) - }) - ) - - throw e + if (variantIdsToDelete) { + await productVariantServiceTx.delete(variantIdsToDelete) } - } - }) - const rawProduct = await productService.retrieve(id, { - select: defaultAdminProductFields, - relations: defaultAdminProductRelations, - }) + if (variantsToUpdate.length) { + await productVariantServiceTx.update(variantsToUpdate) + } + + if (variantsToCreate.length) { + try { + const varTransaction = await createVariantsTransaction( + transactionDependencies, + product.id, + variantsToCreate as CreateProductVariantInput[] + ) + allVariantTransactions.push(varTransaction) + } catch (e) { + await Promise.all( + allVariantTransactions.map(async (transaction) => { + await revertVariantTransaction( + transactionDependencies, + transaction + ).catch(() => logger.warn("Transaction couldn't be reverted.")) + }) + ) + + throw e + } + } + }) + } + + let rawProduct + + if (featureFlagRouter.isFeatureEnabled(IsolateProductDomainFeatureFlag.key)) { + rawProduct = await getProductWithIsolatedProductModule(req, id) + } else { + rawProduct = await productService.retrieve(id, { + select: defaultAdminProductFields, + relations: defaultAdminProductRelations, + }) + } const [product] = await pricingService.setProductPrices([rawProduct]) res.json({ product }) } +async function getProductWithIsolatedProductModule(req, id) { + // TODO: Add support for fields/expands + const remoteQuery = req.scope.resolve("remoteQuery") + + const variables = { id } + + const query = { + product: { + __args: variables, + ...defaultAdminProductRemoteQueryObject, + }, + } + + const [product] = await remoteQuery(query) + + product.profile_id = product.profile?.id + + return product +} + class ProductVariantOptionReq { @IsString() value: string diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index f53effa1ee..e632324873 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -998,7 +998,7 @@ export default class ProductModuleService< if (productVariantIdsToDelete.length) { promises.push( - this.productVariantService_.delete( + this.productVariantService_.softDelete( productVariantIdsToDelete, sharedContext ) @@ -1152,6 +1152,33 @@ export default class ProductModuleService< return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0 } + @InjectManager("baseRepository_") + async restoreVariants< + TReturnableLinkableKeys extends string = Lowercase< + keyof typeof LinkableKeys + > + >( + variantIds: string[], + { returnLinkableKeys }: RestoreReturn = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise, string[]> | void> { + const [_, cascadedEntitiesMap] = await this.productVariantService_.restore( + variantIds, + sharedContext + ) + + let mappedCascadedEntitiesMap + if (returnLinkableKeys) { + mappedCascadedEntitiesMap = mapObjectTo< + Record, string[]> + >(cascadedEntitiesMap, entityNameToLinkableKeysMap, { + pick: returnLinkableKeys, + }) + } + + return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0 + } + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") async restore_( productIds: string[], diff --git a/packages/product/src/services/product-variant.ts b/packages/product/src/services/product-variant.ts index 010a2d3e30..d41300ccb3 100644 --- a/packages/product/src/services/product-variant.ts +++ b/packages/product/src/services/product-variant.ts @@ -154,4 +154,24 @@ export default class ProductVariantService< transactionManager: sharedContext.transactionManager, }) } + + @InjectTransactionManager(doNotForceTransaction, "productVariantRepository_") + async softDelete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.productVariantRepository_.softDelete(ids, { + transactionManager: sharedContext.transactionManager, + }) + } + + @InjectTransactionManager(doNotForceTransaction, "productVariantRepository_") + async restore( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], Record]> { + return await this.productVariantRepository_.restore(ids, { + transactionManager: sharedContext.transactionManager, + }) + } } diff --git a/packages/types/src/inventory/service.ts b/packages/types/src/inventory/service.ts index b110b2801e..ed08d1970e 100644 --- a/packages/types/src/inventory/service.ts +++ b/packages/types/src/inventory/service.ts @@ -127,6 +127,11 @@ export interface IInventoryService { context?: SharedContext ): Promise + restoreInventoryItem( + inventoryItemId: string | string[], + context?: SharedContext + ): Promise + deleteInventoryItemLevelByLocationId( locationId: string | string[], context?: SharedContext diff --git a/packages/types/src/product/service.ts b/packages/types/src/product/service.ts index 0aec405192..418a462f79 100644 --- a/packages/types/src/product/service.ts +++ b/packages/types/src/product/service.ts @@ -2433,4 +2433,10 @@ export interface IProductModuleService { config?: RestoreReturn, sharedContext?: Context ): Promise | void> + + restoreVariants( + variantIds: string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> } diff --git a/packages/types/src/workflow/product/create-products.ts b/packages/types/src/workflow/product/create-products.ts index 9720dfbe0c..9b1f9f21ef 100644 --- a/packages/types/src/workflow/product/create-products.ts +++ b/packages/types/src/workflow/product/create-products.ts @@ -14,7 +14,7 @@ export interface CreateProductSalesChannelInputDTO { id: string } -export interface CraeteProductProductCategoryInputDTO { +export interface CreateProductProductCategoryInputDTO { id: string } @@ -31,7 +31,7 @@ export interface CreateProductVariantPricesInputDTO { max_quantity?: number } -export interface CraeteProductVariantOptionInputDTO { +export interface CreteProductVariantOptionInputDTO { value: string option_id: string } @@ -57,7 +57,7 @@ export interface CreateProductVariantInputDTO { metadata?: Record prices?: CreateProductVariantPricesInputDTO[] - options?: CraeteProductVariantOptionInputDTO[] + options?: CreteProductVariantOptionInputDTO[] } export interface CreateProductInputDTO { @@ -73,7 +73,7 @@ export interface CreateProductInputDTO { type?: CreateProductTypeInputDTO collection_id?: string tags?: CreateProductTagInputDTO[] - categories?: CraeteProductProductCategoryInputDTO[] + categories?: CreateProductProductCategoryInputDTO[] options?: CreateProductOptionInputDTO[] variants?: CreateProductVariantInputDTO[] weight?: number diff --git a/packages/types/src/workflow/product/index.ts b/packages/types/src/workflow/product/index.ts index fa30720e57..673206ae94 100644 --- a/packages/types/src/workflow/product/index.ts +++ b/packages/types/src/workflow/product/index.ts @@ -1 +1,2 @@ export * from "./create-products" +export * from "./update-products" diff --git a/packages/types/src/workflow/product/update-products.ts b/packages/types/src/workflow/product/update-products.ts new file mode 100644 index 0000000000..fa73485151 --- /dev/null +++ b/packages/types/src/workflow/product/update-products.ts @@ -0,0 +1,88 @@ +import { ProductStatus } from "../../product" + +export interface UpdateProductTypeInputDTO { + id?: string + value: string +} + +export interface UpdateProductTagInputDTO { + id?: string + value: string +} + +export interface UpdateProductSalesChannelInputDTO { + id: string +} + +export interface UpdateProductProductCategoryInputDTO { + id: string +} + +export interface UpdateProductVariantPricesInputDTO { + id?: string + region_id?: string + currency_code?: string + amount: number + min_quantity?: number + max_quantity?: number +} + +export interface UpdateProductVariantOptionInputDTO { + value: string + option_id: string +} + +export interface UpdateProductVariantInputDTO { + id?: string + title?: string + sku?: string + ean?: string + upc?: string + barcode?: string + hs_code?: string + inventory_quantity?: number + allow_backorder?: boolean + manage_inventory?: boolean + weight?: number + length?: number + height?: number + width?: number + origin_country?: string + mid_code?: string + material?: string + metadata?: Record + + prices?: UpdateProductVariantPricesInputDTO[] + options?: UpdateProductVariantOptionInputDTO[] +} + +export interface UpdateProductInputDTO { + id: string + title?: string + subtitle?: string + description?: string + discountable?: boolean + images?: string[] + thumbnail?: string + handle?: string + status?: ProductStatus + type?: UpdateProductTypeInputDTO + collection_id?: string + tags?: UpdateProductTagInputDTO[] + sales_channels?: UpdateProductSalesChannelInputDTO[] + categories?: UpdateProductProductCategoryInputDTO[] + variants?: UpdateProductVariantInputDTO[] + weight?: number + length?: number + width?: number + height?: number + hs_code?: string + origin_country?: string + mid_code?: string + material?: string + metadata?: Record +} + +export interface UpdateProductsWorkflowInputDTO { + products: UpdateProductInputDTO[] +} diff --git a/packages/workflows/src/definition/product/index.ts b/packages/workflows/src/definition/product/index.ts index fa30720e57..673206ae94 100644 --- a/packages/workflows/src/definition/product/index.ts +++ b/packages/workflows/src/definition/product/index.ts @@ -1 +1,2 @@ export * from "./create-products" +export * from "./update-products" diff --git a/packages/workflows/src/definition/product/update-products.ts b/packages/workflows/src/definition/product/update-products.ts new file mode 100644 index 0000000000..f0b0bdfc98 --- /dev/null +++ b/packages/workflows/src/definition/product/update-products.ts @@ -0,0 +1,364 @@ +import { ProductTypes, WorkflowTypes } from "@medusajs/types" + +import { InputAlias, Workflows } from "../../definitions" +import { + TransactionStepsDefinition, + WorkflowManager, +} from "@medusajs/orchestration" +import { exportWorkflow, pipe } from "../../helper" +import { CreateProductsActions } from "./create-products" +import { InventoryHandlers, ProductHandlers } from "../../handlers" +import * as MiddlewareHandlers from "../../handlers/middlewares" +import { detachSalesChannelFromProducts } from "../../handlers/product" +import { prepareCreateInventoryItems } from "./prepare-create-inventory-items" + +export enum UpdateProductsActions { + prepare = "prepare", + updateProducts = "updateProducts", + + attachSalesChannels = "attachSalesChannels", + detachSalesChannels = "detachSalesChannels", + + createInventoryItems = "createInventoryItems", + attachInventoryItems = "attachInventoryItems", + detachInventoryItems = "detachInventoryItems", + removeInventoryItems = "removeInventoryItems", +} + +export const updateProductsWorkflowSteps: TransactionStepsDefinition = { + next: { + action: CreateProductsActions.prepare, + noCompensation: true, + next: { + action: UpdateProductsActions.updateProducts, + next: [ + { + action: UpdateProductsActions.attachSalesChannels, + saveResponse: false, + }, + { + action: UpdateProductsActions.detachSalesChannels, + saveResponse: false, + }, + { + // for created variants + action: UpdateProductsActions.createInventoryItems, + next: { + action: UpdateProductsActions.attachInventoryItems, + }, + }, + { + // for deleted variants + action: UpdateProductsActions.detachInventoryItems, + next: { + action: UpdateProductsActions.removeInventoryItems, + }, + }, + ], + }, + }, +} + +const handlers = new Map([ + [ + UpdateProductsActions.prepare, + { + invoke: pipe( + { + merge: true, + inputAlias: InputAlias.ProductsInputData, + invoke: { + from: InputAlias.ProductsInputData, + }, + }, + ProductHandlers.updateProductsPrepareData + ), + }, + ], + [ + UpdateProductsActions.updateProducts, + { + invoke: pipe( + { + merge: true, + invoke: [ + { + from: InputAlias.ProductsInputData, + alias: ProductHandlers.updateProducts.aliases.products, + }, + { + from: UpdateProductsActions.prepare, + }, + ], + }, + ProductHandlers.updateProducts + ), + compensate: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.prepare, + alias: ProductHandlers.revertUpdateProducts.aliases.preparedData, + }, + { + from: UpdateProductsActions.updateProducts, + alias: + MiddlewareHandlers.updateProductsExtractDeletedVariants.aliases + .products, + }, + ], + }, + MiddlewareHandlers.updateProductsExtractDeletedVariants, + ProductHandlers.revertUpdateProducts + ), + }, + ], + [ + UpdateProductsActions.attachSalesChannels, + { + invoke: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.prepare, + alias: "preparedData", + }, + { + from: UpdateProductsActions.updateProducts, + alias: + ProductHandlers.attachSalesChannelToProducts.aliases.products, + }, + ], + }, + MiddlewareHandlers.mapData((d) => ({ + productsHandleSalesChannelsMap: + d.preparedData.productHandleAddedChannelsMap, + })), + ProductHandlers.attachSalesChannelToProducts + ), + compensate: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.prepare, + alias: "preparedData", + }, + { + from: UpdateProductsActions.updateProducts, + alias: detachSalesChannelFromProducts.aliases.products, + }, + ], + }, + MiddlewareHandlers.mapData((d) => ({ + productsHandleSalesChannelsMap: + d.preparedData.productHandleAddedChannelsMap, + })), + ProductHandlers.detachSalesChannelFromProducts + ), + }, + ], + [ + UpdateProductsActions.detachSalesChannels, + { + invoke: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.prepare, + alias: "preparedData", + }, + { + from: UpdateProductsActions.updateProducts, + alias: + ProductHandlers.detachSalesChannelFromProducts.aliases.products, + }, + ], + }, + MiddlewareHandlers.mapData((d) => ({ + productsHandleSalesChannelsMap: + d.preparedData.productHandleRemovedChannelsMap, + })), + ProductHandlers.detachSalesChannelFromProducts + ), + compensate: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.prepare, + alias: "preparedData", + }, + { + from: UpdateProductsActions.updateProducts, + alias: + ProductHandlers.attachSalesChannelToProducts.aliases.products, + }, + ], + }, + MiddlewareHandlers.mapData((d) => ({ + productsHandleSalesChannelsMap: + d.preparedData.productHandleRemovedChannelsMap, + })), + ProductHandlers.attachSalesChannelToProducts + ), + }, + ], + [ + UpdateProductsActions.createInventoryItems, + { + invoke: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.prepare, + alias: + MiddlewareHandlers.updateProductsExtractCreatedVariants.aliases + .preparedData, + }, + { + from: UpdateProductsActions.updateProducts, + alias: + MiddlewareHandlers.updateProductsExtractCreatedVariants.aliases + .products, + }, + ], + }, + MiddlewareHandlers.updateProductsExtractCreatedVariants, + prepareCreateInventoryItems, + InventoryHandlers.createInventoryItems + ), + compensate: pipe( + { + merge: true, + invoke: { + from: UpdateProductsActions.createInventoryItems, + alias: + InventoryHandlers.removeInventoryItems.aliases.inventoryItems, + }, + }, + InventoryHandlers.removeInventoryItems + ), + }, + ], + [ + UpdateProductsActions.attachInventoryItems, + { + invoke: pipe( + { + merge: true, + invoke: { + from: UpdateProductsActions.createInventoryItems, + alias: + InventoryHandlers.attachInventoryItems.aliases.inventoryItems, + }, + }, + InventoryHandlers.attachInventoryItems + ), + compensate: pipe( + { + merge: true, + invoke: { + from: UpdateProductsActions.attachInventoryItems, + alias: + InventoryHandlers.detachInventoryItems.aliases.inventoryItems, + }, + }, + InventoryHandlers.detachInventoryItems + ), + }, + ], + [ + UpdateProductsActions.detachInventoryItems, + { + invoke: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.prepare, + alias: + MiddlewareHandlers.updateProductsExtractDeletedVariants.aliases + .preparedData, + }, + { + from: UpdateProductsActions.updateProducts, + alias: + MiddlewareHandlers.updateProductsExtractDeletedVariants.aliases + .products, + }, + ], + }, + MiddlewareHandlers.updateProductsExtractDeletedVariants, + MiddlewareHandlers.useVariantsInventoryItems, + InventoryHandlers.detachInventoryItems + ), + compensate: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.prepare, + alias: + MiddlewareHandlers.updateProductsExtractDeletedVariants.aliases + .preparedData, + }, + { + from: UpdateProductsActions.updateProducts, + alias: + MiddlewareHandlers.updateProductsExtractDeletedVariants.aliases + .products, + }, + ], + }, + MiddlewareHandlers.updateProductsExtractDeletedVariants, + MiddlewareHandlers.useVariantsInventoryItems, + InventoryHandlers.attachInventoryItems + ), + }, + ], + [ + UpdateProductsActions.removeInventoryItems, + { + invoke: pipe( + { + merge: true, + invoke: { + from: UpdateProductsActions.detachInventoryItems, + alias: + InventoryHandlers.removeInventoryItems.aliases.inventoryItems, + }, + }, + InventoryHandlers.removeInventoryItems + ), + compensate: pipe( + { + merge: true, + invoke: [ + { + from: UpdateProductsActions.removeInventoryItems, + alias: + InventoryHandlers.restoreInventoryItems.aliases.inventoryItems, + }, + ], + }, + InventoryHandlers.restoreInventoryItems + ), + }, + ], +]) + +WorkflowManager.register( + Workflows.UpdateProducts, + updateProductsWorkflowSteps, + handlers +) + +export const updateProducts = exportWorkflow< + WorkflowTypes.ProductWorkflow.UpdateProductsWorkflowInputDTO, + ProductTypes.ProductDTO[] +>(Workflows.UpdateProducts, UpdateProductsActions.updateProducts) diff --git a/packages/workflows/src/definitions.ts b/packages/workflows/src/definitions.ts index 9b99ae77ff..e4b98994cb 100644 --- a/packages/workflows/src/definitions.ts +++ b/packages/workflows/src/definitions.ts @@ -1,6 +1,7 @@ export enum Workflows { // Product workflows CreateProducts = "create-products", + UpdateProducts = "update-products", // Cart workflows CreateCart = "create-cart", diff --git a/packages/workflows/src/handlers/inventory/attach-inventory-items.ts b/packages/workflows/src/handlers/inventory/attach-inventory-items.ts index f44453d6e0..b4cf121035 100644 --- a/packages/workflows/src/handlers/inventory/attach-inventory-items.ts +++ b/packages/workflows/src/handlers/inventory/attach-inventory-items.ts @@ -17,7 +17,7 @@ export async function attachInventoryItems({ .withTransaction(manager) if (!data?.inventoryItems?.length) { - return + return [] } const inventoryData = data.inventoryItems.map(({ tag, inventoryItem }) => ({ @@ -25,7 +25,9 @@ export async function attachInventoryItems({ inventoryItemId: inventoryItem.id, })) - return await productVariantInventoryService.attachInventoryItem(inventoryData) + await productVariantInventoryService.attachInventoryItem(inventoryData) + + return data.inventoryItems } attachInventoryItems.aliases = { diff --git a/packages/workflows/src/handlers/inventory/create-inventory-items.ts b/packages/workflows/src/handlers/inventory/create-inventory-items.ts index 11eb290a60..a8541700f9 100644 --- a/packages/workflows/src/handlers/inventory/create-inventory-items.ts +++ b/packages/workflows/src/handlers/inventory/create-inventory-items.ts @@ -23,7 +23,7 @@ export async function createInventoryItems({ return void 0 } - const result = await Promise.all( + return await Promise.all( data.inventoryItems.map(async (item) => { const inventoryItem = await inventoryService!.createInventoryItem({ sku: item.sku!, @@ -40,8 +40,6 @@ export async function createInventoryItems({ return { tag: item._associationTag ?? inventoryItem.id, inventoryItem } }) ) - - return result } createInventoryItems.aliases = { diff --git a/packages/workflows/src/handlers/inventory/detach-inventory-items.ts b/packages/workflows/src/handlers/inventory/detach-inventory-items.ts index f38d65ed3f..1fa56b382c 100644 --- a/packages/workflows/src/handlers/inventory/detach-inventory-items.ts +++ b/packages/workflows/src/handlers/inventory/detach-inventory-items.ts @@ -10,15 +10,15 @@ export async function detachInventoryItems({ tag: string inventoryItem: InventoryItemDTO }[] -}>): Promise { +}>) { const { manager } = context const productVariantInventoryService = container .resolve("productVariantInventoryService") .withTransaction(manager) - if (!data?.inventoryItems.length) { - return + if (!data?.inventoryItems?.length) { + return [] } await Promise.all( @@ -29,6 +29,8 @@ export async function detachInventoryItems({ ) }) ) + + return data.inventoryItems } detachInventoryItems.aliases = { diff --git a/packages/workflows/src/handlers/inventory/index.ts b/packages/workflows/src/handlers/inventory/index.ts index 7236452125..e6e77139df 100644 --- a/packages/workflows/src/handlers/inventory/index.ts +++ b/packages/workflows/src/handlers/inventory/index.ts @@ -2,3 +2,4 @@ export * from "./detach-inventory-items" export * from "./attach-inventory-items" export * from "./create-inventory-items" export * from "./remove-inventory-items" +export * from "./restore-inventory-items" diff --git a/packages/workflows/src/handlers/inventory/remove-inventory-items.ts b/packages/workflows/src/handlers/inventory/remove-inventory-items.ts index 480bc307f1..5eeb14e08a 100644 --- a/packages/workflows/src/handlers/inventory/remove-inventory-items.ts +++ b/packages/workflows/src/handlers/inventory/remove-inventory-items.ts @@ -14,12 +14,14 @@ export async function removeInventoryItems({ logger.warn( `Inventory service not found. You should install the @medusajs/inventory package to use inventory. The 'removeInventoryItems' will be skipped.` ) - return + return [] } - return await inventoryService!.deleteInventoryItem( + await inventoryService!.deleteInventoryItem( data.inventoryItems.map(({ inventoryItem }) => inventoryItem.id) ) + + return data.inventoryItems } removeInventoryItems.aliases = { diff --git a/packages/workflows/src/handlers/inventory/restore-inventory-items.ts b/packages/workflows/src/handlers/inventory/restore-inventory-items.ts new file mode 100644 index 0000000000..d4f1c61cb0 --- /dev/null +++ b/packages/workflows/src/handlers/inventory/restore-inventory-items.ts @@ -0,0 +1,35 @@ +import { + IInventoryService, + InventoryItemDTO, + SharedContext, +} from "@medusajs/types" +import { WorkflowArguments } from "../../helper" + +export async function restoreInventoryItems({ + container, + context, + data, +}: WorkflowArguments<{ + inventoryItems: { inventoryItem: InventoryItemDTO }[] +}>) { + const { manager } = context as SharedContext + const inventoryService: IInventoryService = + container.resolve("inventoryService") + + if (!inventoryService) { + const logger = container.resolve("logger") + logger.warn( + `Inventory service not found. You should install the @medusajs/inventory package to use inventory. The 'removeInventoryItems' will be skipped.` + ) + return + } + + return await inventoryService!.restoreInventoryItem( + data.inventoryItems.map(({ inventoryItem }) => inventoryItem.id), + { transactionManager: manager } + ) +} + +restoreInventoryItems.aliases = { + inventoryItems: "inventoryItems", +} diff --git a/packages/workflows/src/handlers/middlewares/extract-variants.ts b/packages/workflows/src/handlers/middlewares/extract-variants.ts new file mode 100644 index 0000000000..01bb535176 --- /dev/null +++ b/packages/workflows/src/handlers/middlewares/extract-variants.ts @@ -0,0 +1,28 @@ +import { ProductTypes } from "@medusajs/types" + +import { WorkflowArguments } from "../../helper" + +export async function extractVariants({ + data, +}: WorkflowArguments<{ + object: { variants?: ProductTypes.ProductVariantDTO[] }[] +}>) { + const variants = data.object.reduce((acc, object) => { + if (object.variants?.length) { + return acc.concat(object.variants) + } + return acc + }, [] as ProductTypes.ProductVariantDTO[]) + + return { + alias: extractVariants.aliases.output, + value: { + variants, + }, + } +} + +extractVariants.aliases = { + output: "extractVariantsFromProductOutput", + object: "object", +} diff --git a/packages/workflows/src/handlers/middlewares/index.ts b/packages/workflows/src/handlers/middlewares/index.ts index 1b392ad4b9..f52f28c7a2 100644 --- a/packages/workflows/src/handlers/middlewares/index.ts +++ b/packages/workflows/src/handlers/middlewares/index.ts @@ -1 +1,6 @@ export * from "./create-products-prepare-create-prices-compensation" +export * from "./update-products-extract-created-variants" +export * from "./update-products-extract-deleted-variants" +export * from "./use-variants-inventory-items" +export * from "./extract-variants" +export * from "./map-data" diff --git a/packages/workflows/src/handlers/middlewares/map-data.ts b/packages/workflows/src/handlers/middlewares/map-data.ts new file mode 100644 index 0000000000..9c560cf366 --- /dev/null +++ b/packages/workflows/src/handlers/middlewares/map-data.ts @@ -0,0 +1,16 @@ +import { WorkflowArguments } from "../../helper" + +/** + * Middleware for map input data to a key/s. + * + * @param mapFn - apply function on the input data and return result as the middleware output + * @param alias - key to save output under (if `merge === false`) + */ +export function mapData(mapFn: (arg: T) => S, alias = "mapData") { + return async function ({ data }: WorkflowArguments) { + return { + alias, + value: mapFn(data), + } + } +} diff --git a/packages/workflows/src/handlers/middlewares/update-products-extract-created-variants.ts b/packages/workflows/src/handlers/middlewares/update-products-extract-created-variants.ts new file mode 100644 index 0000000000..b10079acef --- /dev/null +++ b/packages/workflows/src/handlers/middlewares/update-products-extract-created-variants.ts @@ -0,0 +1,40 @@ +import { ProductTypes, ProductVariantDTO } from "@medusajs/types" + +import { WorkflowArguments } from "../../helper" +import { UpdateProductsPreparedData } from "../product" + +export async function updateProductsExtractCreatedVariants({ + data, +}: WorkflowArguments<{ + preparedData: UpdateProductsPreparedData // products state before the update + products: ProductTypes.ProductDTO[] // updated products +}>) { + const createdVariants: ProductVariantDTO[] = [] + + data.products.forEach((product) => { + const addedVariants: ProductVariantDTO[] = [] + + const originalProduct = data.preparedData.originalProducts.find( + (p) => p.id === product.id + )! + + product.variants.forEach((variant) => { + if (!originalProduct.variants.find((v) => v.id === variant.id)) { + addedVariants.push(variant) + } + }) + + createdVariants.push(...addedVariants) + }) + + return { + alias: updateProductsExtractCreatedVariants.aliases.output, + value: [{ variants: createdVariants }], + } +} + +updateProductsExtractCreatedVariants.aliases = { + preparedData: "preparedData", + products: "products", + output: "products", +} diff --git a/packages/workflows/src/handlers/middlewares/update-products-extract-deleted-variants.ts b/packages/workflows/src/handlers/middlewares/update-products-extract-deleted-variants.ts new file mode 100644 index 0000000000..124a2be959 --- /dev/null +++ b/packages/workflows/src/handlers/middlewares/update-products-extract-deleted-variants.ts @@ -0,0 +1,43 @@ +import { ProductTypes, ProductVariantDTO } from "@medusajs/types" + +import { WorkflowArguments } from "../../helper" +import { UpdateProductsPreparedData } from "../product" + +export async function updateProductsExtractDeletedVariants({ + data, + container, +}: WorkflowArguments<{ + preparedData: UpdateProductsPreparedData // products state before the update + products: ProductTypes.ProductDTO[] // updated products +}>) { + const deletedVariants: ProductVariantDTO[] = [] + + data.products.forEach((product) => { + const removedVariants: ProductVariantDTO[] = [] + + const originalProduct = data.preparedData.originalProducts.find( + (p) => p.id === product.id + )! + + originalProduct.variants.forEach((variant) => { + if (!product.variants.find((v) => v.id === variant.id)) { + removedVariants.push(variant) + } + }) + + deletedVariants.push(...removedVariants) + }) + + return { + alias: updateProductsExtractDeletedVariants.aliases.output, + value: { + variants: deletedVariants, + }, + } +} + +updateProductsExtractDeletedVariants.aliases = { + preparedData: "preparedData", + products: "products", + output: "updateProductsExtractDeletedVariantsOutput", +} diff --git a/packages/workflows/src/handlers/middlewares/use-variants-inventory-items.ts b/packages/workflows/src/handlers/middlewares/use-variants-inventory-items.ts new file mode 100644 index 0000000000..7c74361c34 --- /dev/null +++ b/packages/workflows/src/handlers/middlewares/use-variants-inventory-items.ts @@ -0,0 +1,46 @@ +import { WorkflowArguments } from "../../helper" +import { IInventoryService, ProductVariantDTO } from "@medusajs/types" + +export async function useVariantsInventoryItems({ + data, + container, +}: WorkflowArguments<{ + updateProductsExtractDeletedVariantsOutput: { variants: ProductVariantDTO[] } +}>) { + const inventoryService: IInventoryService = + container.resolve("inventoryService") + + if (!inventoryService) { + const logger = container.resolve("logger") + logger.warn( + `Inventory service not found. You should install the @medusajs/inventory package to use inventory. The 'useVariantsInventoryItems' will be skipped.` + ) + return { + alias: useVariantsInventoryItems.aliases.output, + value: null, + } + } + + const [inventoryItems] = await inventoryService!.listInventoryItems({ + sku: data.updateProductsExtractDeletedVariantsOutput.variants.map( + (v) => v.id + ), + }) + + const variantItems = inventoryItems.map((item) => ({ + inventoryItem: item, + tag: data.updateProductsExtractDeletedVariantsOutput.variants.find( + (variant) => variant.sku === item.sku + )!.id, + })) + + return { + alias: useVariantsInventoryItems.aliases.output, + value: { inventoryItems: variantItems }, + } +} + +useVariantsInventoryItems.aliases = { + variants: "variants", + output: "useVariantsInventoryItemsOutput", +} diff --git a/packages/workflows/src/handlers/product/index.ts b/packages/workflows/src/handlers/product/index.ts index 6c3c1215c0..d15f60321a 100644 --- a/packages/workflows/src/handlers/product/index.ts +++ b/packages/workflows/src/handlers/product/index.ts @@ -6,4 +6,7 @@ export * from "./detach-shipping-profile-from-products" export * from "./remove-products" export * from "./attach-shipping-profile-to-products" export * from "./list-products" +export * from "./update-products" +export * from "./update-products-prepare-data" +export * from "./revert-update-products" export * from "./update-products-variants-prices" diff --git a/packages/workflows/src/handlers/product/revert-update-products.ts b/packages/workflows/src/handlers/product/revert-update-products.ts new file mode 100644 index 0000000000..a78748987c --- /dev/null +++ b/packages/workflows/src/handlers/product/revert-update-products.ts @@ -0,0 +1,38 @@ +import { Modules, ModulesDefinition } from "@medusajs/modules-sdk" +import { + ProductDTO, + ProductTypes, + ProductVariantDTO, + UpdateProductDTO, +} from "@medusajs/types" + +import { WorkflowArguments } from "../../helper" +import { UpdateProductsPreparedData } from "./update-products-prepare-data" + +type HandlerInput = UpdateProductsPreparedData & { + variants: ProductVariantDTO[] +} + +export async function revertUpdateProducts({ + container, + data, +}: WorkflowArguments): Promise { + const productModuleService: ProductTypes.IProductModuleService = + container.resolve(ModulesDefinition[Modules.PRODUCT].registrationName) + + // restore variants that have been soft deleted during update products step + await productModuleService.restoreVariants(data.variants.map((v) => v.id)) + data.originalProducts.forEach((product) => { + // @ts-ignore + product.variants = product.variants.map((v) => ({ id: v.id })) + }) + + return await productModuleService.update( + data.originalProducts as unknown as UpdateProductDTO[] + ) +} + +revertUpdateProducts.aliases = { + preparedData: "preparedData", + variants: "variants", +} diff --git a/packages/workflows/src/handlers/product/update-products-prepare-data.ts b/packages/workflows/src/handlers/product/update-products-prepare-data.ts new file mode 100644 index 0000000000..6d0d914b3e --- /dev/null +++ b/packages/workflows/src/handlers/product/update-products-prepare-data.ts @@ -0,0 +1,81 @@ +import { ProductDTO, SalesChannelDTO, WorkflowTypes } from "@medusajs/types" + +import { WorkflowArguments } from "../../helper" + +type ProductWithSalesChannelsDTO = ProductDTO & { + sales_channels?: SalesChannelDTO[] +} + +export type UpdateProductsPreparedData = { + originalProducts: ProductWithSalesChannelsDTO[] + productHandleAddedChannelsMap: Map + productHandleRemovedChannelsMap: Map +} + +export async function updateProductsPrepareData({ + container, + context, + data, +}: WorkflowArguments): Promise { + const ids = data.products.map((product) => product.id) + + const productHandleAddedChannelsMap = new Map() + const productHandleRemovedChannelsMap = new Map() + + const productService = container.resolve("productService") + const productServiceTx = productService.withTransaction(context.manager) + + const products = await productServiceTx.list( + // TODO: use RemoteQuery - sales_channels needs to be added to the joiner config + { id: ids }, + { + relations: [ + "variants", + "variants.options", + "images", + "options", + "tags", + "collection", + "sales_channels", + ], + } + ) + + data.products.forEach((productInput) => { + const removedChannels: string[] = [] + const addedChannels: string[] = [] + + const currentProduct = products.find( + (p) => p.id === productInput.id + ) as unknown as ProductWithSalesChannelsDTO + + if (productInput.sales_channels) { + productInput.sales_channels.forEach((channel) => { + if ( + !currentProduct.sales_channels?.find((sc) => sc.id === channel.id) + ) { + addedChannels.push(channel.id) + } + }) + + currentProduct.sales_channels?.forEach((channel) => { + if (!productInput.sales_channels!.find((sc) => sc.id === channel.id)) { + removedChannels.push(channel.id) + } + }) + } + + productHandleAddedChannelsMap.set(currentProduct.handle!, addedChannels) + productHandleRemovedChannelsMap.set(currentProduct.handle!, removedChannels) + }) + + return { + originalProducts: products, + productHandleAddedChannelsMap, + productHandleRemovedChannelsMap, + } +} + +updateProductsPrepareData.aliases = { + preparedData: "preparedData", +} diff --git a/packages/workflows/src/handlers/product/update-products.ts b/packages/workflows/src/handlers/product/update-products.ts new file mode 100644 index 0000000000..ee28cadb74 --- /dev/null +++ b/packages/workflows/src/handlers/product/update-products.ts @@ -0,0 +1,44 @@ +import { Modules, ModulesDefinition } from "@medusajs/modules-sdk" +import { ProductDTO, ProductTypes } from "@medusajs/types" + +import { WorkflowArguments } from "../../helper" + +type HandlerInput = { + products: ProductTypes.UpdateProductDTO[] +} + +export async function updateProducts({ + container, + context, + data, +}: WorkflowArguments): Promise { + if (!data.products.length) { + return [] + } + + const productModuleService: ProductTypes.IProductModuleService = + container.resolve(ModulesDefinition[Modules.PRODUCT].registrationName) + + const products = await productModuleService.update(data.products) + + return await productModuleService.list( + { id: products.map((p) => p.id) }, + { + relations: [ + "variants", + "variants.options", + "images", + "options", + "tags", + // "type", + "collection", + // "profiles", + // "sales_channels", + ], + } + ) +} + +updateProducts.aliases = { + products: "products", +}