feat(workflows): update product workflow (#4982)

**What**
- added "update product" workflow

Co-authored-by: Riqwan Thamir <5105988+riqwan@users.noreply.github.com>
This commit is contained in:
Frane Polić
2023-10-19 14:02:40 +02:00
committed by GitHub
parent 3aba6269ed
commit aba9ded2a3
34 changed files with 1511 additions and 126 deletions

View File

@@ -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) }),
])
})
})
})

View File

@@ -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" })],
})
)
})
})

View File

@@ -35,6 +35,7 @@ module.exports = {
featureFlags: {
workflows: {
[Workflows.CreateProducts]: true,
[Workflows.UpdateProducts]: true,
[Workflows.CreateCart]: true,
},
},

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class ScopeLevelUnique1697708391459 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
CREATE UNIQUE INDEX "UQ_inventory_level_inventory_item_id_location_id" ON "inventory_level" ("inventory_item_id", "location_id");
`)
}
}

View File

@@ -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<void> {
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,
})
}
}

View File

@@ -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<void> {
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,

View File

@@ -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<void> {
await this.inventoryLevelService_.restoreByInventoryItemId(
inventoryItemId,
context
)
return await this.inventoryItemService_.restore(inventoryItemId, context)
}
@InjectEntityManager(
(target) =>
target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED

View File

@@ -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

View File

@@ -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<TReturnableLinkableKeys> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<Record<Lowercase<keyof typeof LinkableKeys>, string[]> | void> {
const [_, cascadedEntitiesMap] = await this.productVariantService_.restore(
variantIds,
sharedContext
)
let mappedCascadedEntitiesMap
if (returnLinkableKeys) {
mappedCascadedEntitiesMap = mapObjectTo<
Record<Lowercase<keyof typeof LinkableKeys>, string[]>
>(cascadedEntitiesMap, entityNameToLinkableKeysMap, {
pick: returnLinkableKeys,
})
}
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async restore_(
productIds: string[],

View File

@@ -154,4 +154,24 @@ export default class ProductVariantService<
transactionManager: sharedContext.transactionManager,
})
}
@InjectTransactionManager(doNotForceTransaction, "productVariantRepository_")
async softDelete(
ids: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.productVariantRepository_.softDelete(ids, {
transactionManager: sharedContext.transactionManager,
})
}
@InjectTransactionManager(doNotForceTransaction, "productVariantRepository_")
async restore(
ids: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], Record<string, unknown[]>]> {
return await this.productVariantRepository_.restore(ids, {
transactionManager: sharedContext.transactionManager,
})
}
}

View File

@@ -127,6 +127,11 @@ export interface IInventoryService {
context?: SharedContext
): Promise<void>
restoreInventoryItem(
inventoryItemId: string | string[],
context?: SharedContext
): Promise<void>
deleteInventoryItemLevelByLocationId(
locationId: string | string[],
context?: SharedContext

View File

@@ -2433,4 +2433,10 @@ export interface IProductModuleService {
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
restoreVariants<TReturnableLinkableKeys extends string = string>(
variantIds: string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
}

View File

@@ -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<string, unknown>
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

View File

@@ -1 +1,2 @@
export * from "./create-products"
export * from "./update-products"

View File

@@ -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<string, unknown>
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<string, unknown>
}
export interface UpdateProductsWorkflowInputDTO {
products: UpdateProductInputDTO[]
}

View File

@@ -1 +1,2 @@
export * from "./create-products"
export * from "./update-products"

View File

@@ -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)

View File

@@ -1,6 +1,7 @@
export enum Workflows {
// Product workflows
CreateProducts = "create-products",
UpdateProducts = "update-products",
// Cart workflows
CreateCart = "create-cart",

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -10,15 +10,15 @@ export async function detachInventoryItems({
tag: string
inventoryItem: InventoryItemDTO
}[]
}>): Promise<void> {
}>) {
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 = {

View File

@@ -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"

View File

@@ -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 = {

View File

@@ -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",
}

View File

@@ -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",
}

View File

@@ -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"

View File

@@ -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<T, S>(mapFn: (arg: T) => S, alias = "mapData") {
return async function ({ data }: WorkflowArguments<T>) {
return {
alias,
value: mapFn(data),
}
}
}

View File

@@ -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",
}

View File

@@ -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",
}

View File

@@ -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",
}

View File

@@ -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"

View File

@@ -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<HandlerInput>): Promise<ProductDTO[]> {
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",
}

View File

@@ -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<string, string[]>
productHandleRemovedChannelsMap: Map<string, string[]>
}
export async function updateProductsPrepareData({
container,
context,
data,
}: WorkflowArguments<WorkflowTypes.ProductWorkflow.UpdateProductsWorkflowInputDTO>): Promise<UpdateProductsPreparedData> {
const ids = data.products.map((product) => product.id)
const productHandleAddedChannelsMap = new Map<string, string[]>()
const productHandleRemovedChannelsMap = new Map<string, string[]>()
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",
}

View File

@@ -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<HandlerInput>): Promise<ProductDTO[]> {
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",
}