Feat: TO variant creation (#3097)

This commit is contained in:
Carlos R. L. Rodrigues
2023-02-02 09:01:10 -03:00
committed by GitHub
parent a049987215
commit d50db84a33
17 changed files with 853 additions and 459 deletions
@@ -3,6 +3,7 @@ import { IsBoolean, IsNumber, IsOptional, IsString } from "class-validator"
import { IInventoryService } from "../../../../interfaces"
import { FindParams } from "../../../../types/common"
import { EntityManager } from "typeorm"
/**
* @oas [post] /inventory-items/{id}
@@ -71,11 +72,16 @@ export default async (req: Request, res: Response) => {
const inventoryService: IInventoryService =
req.scope.resolve("inventoryService")
const manager: EntityManager = req.scope.resolve("manager")
await inventoryService.updateInventoryItem(
id,
req.validatedBody as AdminPostInventoryItemsInventoryItemReq
)
await manager.transaction(async (transactionManager) => {
await inventoryService
.withTransaction(transactionManager)
.updateInventoryItem(
id,
req.validatedBody as AdminPostInventoryItemsInventoryItemReq
)
})
const inventoryItem = await inventoryService.retrieveInventoryItem(
id,
@@ -3,6 +3,7 @@ import { IsOptional, IsNumber } from "class-validator"
import { IInventoryService } from "../../../../interfaces"
import { FindParams } from "../../../../types/common"
import { EntityManager } from "typeorm"
/**
* @oas [post] /inventory-items/{id}/location-levels/{location_id}
@@ -72,11 +73,16 @@ export default async (req: Request, res: Response) => {
const inventoryService: IInventoryService =
req.scope.resolve("inventoryService")
const manager: EntityManager = req.scope.resolve("manager")
const validatedBody =
req.validatedBody as AdminPostInventoryItemsItemLocationLevelsLevelReq
await inventoryService.updateInventoryLevel(id, location_id, validatedBody)
await manager.transaction(async (transactionManager) => {
await inventoryService
.withTransaction(transactionManager)
.updateInventoryLevel(id, location_id, validatedBody)
})
const inventoryItem = await inventoryService.retrieveInventoryItem(
id,
@@ -1,6 +1,8 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
import { EventBusServiceMock } from "../../../../../services/__mocks__/event-bus"
describe("POST /admin/products/:id", () => {
describe("successfully updates a product", () => {
@@ -9,12 +11,17 @@ describe("POST /admin/products/:id", () => {
beforeAll(async () => {
subject = await request(
"POST",
`/admin/products/${IdMap.getId("product1")}`,
`/admin/products/${IdMap.getId("multipleVariants")}`,
{
payload: {
title: "Product 1",
description: "Updated test description",
handle: "handle",
variants: [
{ id: IdMap.getId("variant_1"), title: "Green" },
{ title: "Blue" },
{ title: "Yellow" },
],
},
adminSession: {
jwt: {
@@ -32,7 +39,7 @@ describe("POST /admin/products/:id", () => {
it("calls update", () => {
expect(ProductServiceMock.update).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.update).toHaveBeenCalledWith(
IdMap.getId("product1"),
IdMap.getId("multipleVariants"),
expect.objectContaining({
title: "Product 1",
description: "Updated test description",
@@ -40,51 +47,35 @@ describe("POST /admin/products/:id", () => {
})
)
})
it("successfully updates variants and create new ones", async () => {
expect(ProductVariantServiceMock.delete).toHaveBeenCalledTimes(2)
expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.create).toHaveBeenCalledTimes(2)
})
})
describe("handles failed update operation", () => {
it("throws if metadata is to be updated", async () => {
try {
await request("POST", `/admin/products/${IdMap.getId("product1")}`, {
it("throws on wrong variant in update", async () => {
const subject = await request(
"POST",
`/admin/products/${IdMap.getId("variantsWithPrices")}`,
{
payload: {
_id: IdMap.getId("product1"),
title: "Product 1",
metadata: "Test Description",
variants: [{ id: "test_321", title: "Green" }],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
} catch (error) {
expect(error.status).toEqual(400)
expect(error.message).toEqual(
"Use setMetadata to update metadata fields"
)
}
})
it("throws if variants is to be updated", async () => {
try {
await request("POST", `/admin/products/${IdMap.getId("product1")}`, {
payload: {
_id: IdMap.getId("product1"),
title: "Product 1",
metadata: "Test Description",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
} catch (error) {
expect(error.status).toEqual(400)
expect(error.message).toEqual(
"Use addVariant, reorderVariants, removeVariant to update Product Variants"
)
}
}
)
expect(subject.status).toEqual(404)
expect(subject.error.text).toEqual(
`{"type":"not_found","message":"Variant with id: test_321 is not associated with this product"}`
)
})
})
})
@@ -12,6 +12,7 @@ import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import {
PricingService,
ProductService,
ProductVariantInventoryService,
ProductVariantService,
ShippingProfileService,
} from "../../../../services"
@@ -32,6 +33,14 @@ import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-cha
import { ProductStatus } from "../../../../models"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { validator } from "../../../../utils/validator"
import { IInventoryService } from "../../../../interfaces"
import {
createVariantTransaction,
revertVariantTransaction,
} from "./transaction/create-product-variant"
import { DistributedTransaction } from "../../../../utils/transaction"
import { Logger } from "../../../../types/global"
/**
* @oas [post] /products
@@ -98,6 +107,7 @@ import { validator } from "../../../../utils/validator"
export default async (req, res) => {
const validated = await validator(AdminPostProductsReq, req.body)
const logger: Logger = req.scope.resolve("logger")
const productService: ProductService = req.scope.resolve("productService")
const pricingService: PricingService = req.scope.resolve("pricingService")
const productVariantService: ProductVariantService = req.scope.resolve(
@@ -106,6 +116,10 @@ export default async (req, res) => {
const shippingProfileService: ShippingProfileService = req.scope.resolve(
"shippingProfileService"
)
const productVariantInventoryService: ProductVariantInventoryService =
req.scope.resolve("productVariantInventoryService")
const inventoryService: IInventoryService | undefined =
req.scope.resolve("inventoryService")
const entityManager: EntityManager = req.scope.resolve("manager")
@@ -134,8 +148,8 @@ export default async (req, res) => {
.create({ ...validated, profile_id: shippingProfile.id })
if (variants) {
for (const [i, variant] of variants.entries()) {
variant["variant_rank"] = i
for (const [index, variant] of variants.entries()) {
variant["variant_rank"] = index
}
const optionIds =
@@ -143,22 +157,48 @@ export default async (req, res) => {
(o) => newProduct.options.find((newO) => newO.title === o.title)?.id
) || []
await Promise.all(
variants.map(async (v) => {
const variant = {
...v,
options:
v?.options?.map((o, index) => ({
...o,
option_id: optionIds[index],
})) || [],
}
const allVariantTransactions: DistributedTransaction[] = []
const transactionDependencies = {
manager,
inventoryService,
productVariantInventoryService,
productVariantService,
}
await productVariantService
.withTransaction(manager)
.create(newProduct.id, variant as CreateProductVariantInput)
})
)
try {
await Promise.all(
variants.map(async (variant) => {
const options =
variant?.options?.map((option, index) => ({
...option,
option_id: optionIds[index],
})) || []
const input = {
...variant,
options,
}
const varTransation = await createVariantTransaction(
transactionDependencies,
newProduct.id,
input as CreateProductVariantInput
)
allVariantTransactions.push(varTransation)
})
)
} catch (e) {
await Promise.all(
allVariantTransactions.map(async (transaction) => {
await revertVariantTransaction(
transactionDependencies,
transaction
).catch(() => logger.warn("Transaction couldn't be reverted."))
})
)
throw e
}
}
return newProduct
@@ -22,18 +22,10 @@ import {
} from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
import {
TransactionHandlerType,
TransactionOrchestrator,
TransactionPayload,
TransactionState,
TransactionStepsDefinition,
} from "../../../../utils/transaction"
import { ulid } from "ulid"
import { MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { createVariantTransaction } from "./transaction/create-product-variant"
/**
* @oas [post] /products/{id}/variants
* operationId: "PostProductsProductVariants"
@@ -122,47 +114,6 @@ import { EntityManager } from "typeorm"
* $ref: "#/components/responses/500_error"
*/
enum actions {
createVariant = "createVariant",
createInventoryItem = "createInventoryItem",
attachInventoryItem = "attachInventoryItem",
}
const simpleFlow: TransactionStepsDefinition = {
next: {
action: actions.createVariant,
maxRetries: 0,
},
}
const flowWithInventory: TransactionStepsDefinition = {
next: {
action: actions.createVariant,
forwardResponse: true,
maxRetries: 0,
next: {
action: actions.createInventoryItem,
forwardResponse: true,
maxRetries: 0,
next: {
action: actions.attachInventoryItem,
noCompensation: true,
maxRetries: 0,
},
},
},
}
const createSimpleVariantStrategy = new TransactionOrchestrator(
"create-variant",
simpleFlow
)
const createVariantStrategyWithInventory = new TransactionOrchestrator(
"create-variant-with-inventory",
flowWithInventory
)
export default async (req, res) => {
const { id } = req.params
@@ -179,129 +130,19 @@ export default async (req, res) => {
"productVariantService"
)
const createdId: Record<string, string | null> = {
variant: null,
inventoryItem: null,
}
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
const inventoryServiceTx =
inventoryService?.withTransaction(transactionManager)
const productVariantInventoryServiceTx =
productVariantInventoryService.withTransaction(transactionManager)
const productVariantServiceTx =
productVariantService.withTransaction(transactionManager)
async function createVariant() {
const variant = await productVariantServiceTx.create(
id,
validated as CreateProductVariantInput
)
createdId.variant = variant.id
return { variant }
}
async function removeVariant() {
if (createdId.variant) {
await productVariantServiceTx.delete(createdId.variant)
}
}
async function createInventoryItem(variant) {
if (!validated.manage_inventory) {
return
}
const inventoryItem = await inventoryServiceTx!.createInventoryItem({
sku: validated.sku,
origin_country: validated.origin_country,
hs_code: validated.hs_code,
mid_code: validated.mid_code,
material: validated.material,
weight: validated.weight,
length: validated.length,
height: validated.height,
width: validated.width,
})
createdId.inventoryItem = inventoryItem.id
return { variant, inventoryItem }
}
async function removeInventoryItem() {
if (createdId.inventoryItem) {
await inventoryServiceTx!.deleteInventoryItem(createdId.inventoryItem)
}
}
async function attachInventoryItem(variant, inventoryItem) {
if (!validated.manage_inventory) {
return
}
await productVariantInventoryServiceTx.attachInventoryItem(
variant.id,
inventoryItem.id,
validated.inventory_quantity
)
}
async function transactionHandler(
actionId: string,
type: TransactionHandlerType,
payload: TransactionPayload
) {
const command = {
[actions.createVariant]: {
[TransactionHandlerType.INVOKE]: async () => {
return await createVariant()
},
[TransactionHandlerType.COMPENSATE]: async () => {
await removeVariant()
},
},
[actions.createInventoryItem]: {
[TransactionHandlerType.INVOKE]: async (data) => {
const { variant } = data._response ?? {}
return await createInventoryItem(variant)
},
[TransactionHandlerType.COMPENSATE]: async () => {
await removeInventoryItem()
},
},
[actions.attachInventoryItem]: {
[TransactionHandlerType.INVOKE]: async (data) => {
const { variant, inventoryItem } = data._response ?? {}
return await attachInventoryItem(variant, inventoryItem)
},
},
}
return command[actionId][type](payload.data)
}
const strategy = inventoryService
? createVariantStrategyWithInventory
: createSimpleVariantStrategy
const transaction = await strategy.beginTransaction(
ulid(),
transactionHandler,
validated
await createVariantTransaction(
{
manager: transactionManager,
inventoryService,
productVariantInventoryService,
productVariantService,
},
id,
validated as CreateProductVariantInput
)
await strategy.resume(transaction)
if (transaction.getState() !== TransactionState.DONE) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
transaction.errors.map((err) => err.error?.message).join("\n")
)
}
})
const productService: ProductService = req.scope.resolve("productService")
@@ -0,0 +1,225 @@
import {
DistributedTransaction,
TransactionHandlerType,
TransactionOrchestrator,
TransactionPayload,
TransactionState,
TransactionStepsDefinition,
} from "../../../../../utils/transaction"
import { ulid } from "ulid"
import { EntityManager } from "typeorm"
import { IInventoryService } from "../../../../../interfaces"
import {
ProductVariantInventoryService,
ProductVariantService,
} from "../../../../../services"
import { CreateProductVariantInput } from "../../../../../types/product-variant"
import { InventoryItemDTO } from "../../../../../types/inventory"
import { ProductVariant } from "../../../../../models"
import { MedusaError } from "medusa-core-utils"
enum actions {
createVariant = "createVariant",
createInventoryItem = "createInventoryItem",
attachInventoryItem = "attachInventoryItem",
}
const simpleFlow: TransactionStepsDefinition = {
next: {
action: actions.createVariant,
},
}
const flowWithInventory: TransactionStepsDefinition = {
next: {
action: actions.createVariant,
saveResponse: true,
next: {
action: actions.createInventoryItem,
saveResponse: true,
next: {
action: actions.attachInventoryItem,
noCompensation: true,
},
},
},
}
const createSimpleVariantStrategy = new TransactionOrchestrator(
"create-variant",
simpleFlow
)
const createVariantStrategyWithInventory = new TransactionOrchestrator(
"create-variant-with-inventory",
flowWithInventory
)
type InjectedDependencies = {
manager: EntityManager
productVariantService: ProductVariantService
productVariantInventoryService: ProductVariantInventoryService
inventoryService?: IInventoryService
}
export const createVariantTransaction = async (
dependencies: InjectedDependencies,
productId: string,
input: CreateProductVariantInput
): Promise<DistributedTransaction> => {
const {
manager,
productVariantService,
inventoryService,
productVariantInventoryService,
} = dependencies
const inventoryServiceTx = inventoryService?.withTransaction(manager)
const productVariantInventoryServiceTx =
productVariantInventoryService.withTransaction(manager)
const productVariantServiceTx = productVariantService.withTransaction(manager)
async function createVariant(variantInput: CreateProductVariantInput) {
const variant = await productVariantServiceTx.create(
productId,
variantInput
)
return { variant }
}
async function removeVariant(variant: ProductVariant) {
if (variant) {
await productVariantServiceTx.delete(variant.id)
}
}
async function createInventoryItem(variant: ProductVariant) {
if (!variant.manage_inventory) {
return
}
const inventoryItem = await inventoryServiceTx!.createInventoryItem({
sku: variant.sku,
origin_country: variant.origin_country,
hs_code: variant.hs_code,
mid_code: variant.mid_code,
material: variant.material,
weight: variant.weight,
length: variant.length,
height: variant.height,
width: variant.width,
})
return { inventoryItem }
}
async function removeInventoryItem(inventoryItem: InventoryItemDTO) {
if (inventoryItem) {
await inventoryServiceTx!.deleteInventoryItem(inventoryItem.id)
}
}
async function attachInventoryItem(
variant: ProductVariant,
inventoryItem: InventoryItemDTO
) {
if (!variant.manage_inventory) {
return
}
await productVariantInventoryServiceTx.attachInventoryItem(
variant.id,
inventoryItem.id
)
}
async function transactionHandler(
actionId: string,
type: TransactionHandlerType,
payload: TransactionPayload
) {
const command = {
[actions.createVariant]: {
[TransactionHandlerType.INVOKE]: async (
data: CreateProductVariantInput
) => {
return await createVariant(data)
},
[TransactionHandlerType.COMPENSATE]: async (
data: CreateProductVariantInput,
{ invoke }
) => {
await removeVariant(invoke[actions.createVariant])
},
},
[actions.createInventoryItem]: {
[TransactionHandlerType.INVOKE]: async (
data: CreateProductVariantInput,
{ invoke }
) => {
const { [actions.createVariant]: variant } = invoke
return await createInventoryItem(variant)
},
[TransactionHandlerType.COMPENSATE]: async (
data: CreateProductVariantInput,
{ invoke }
) => {
await removeInventoryItem(invoke[actions.createInventoryItem])
},
},
[actions.attachInventoryItem]: {
[TransactionHandlerType.INVOKE]: async (
data: CreateProductVariantInput,
{ invoke }
) => {
const {
[actions.createVariant]: variant,
[actions.createInventoryItem]: inventoryItem,
} = invoke
return await attachInventoryItem(variant, inventoryItem)
},
},
}
return command[actionId][type](payload.data, payload.context)
}
const strategy = inventoryService
? createVariantStrategyWithInventory
: createSimpleVariantStrategy
const transaction = await strategy.beginTransaction(
ulid(),
transactionHandler,
input
)
await strategy.resume(transaction)
if (transaction.getState() !== TransactionState.DONE) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
transaction
.getErrors()
.map((err) => err.error?.message)
.join("\n")
)
}
return transaction
}
export const revertVariantTransaction = async (
dependencies: InjectedDependencies,
transaction: DistributedTransaction
) => {
const { inventoryService } = dependencies
const strategy = inventoryService
? createVariantStrategyWithInventory
: createSimpleVariantStrategy
await strategy.cancelTransaction(transaction)
}
@@ -12,7 +12,12 @@ import {
ValidateNested,
} from "class-validator"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { PricingService, ProductService } from "../../../../services"
import {
PricingService,
ProductService,
ProductVariantInventoryService,
ProductVariantService,
} from "../../../../services"
import {
ProductSalesChannelReq,
ProductTagReq,
@@ -23,10 +28,21 @@ import {
import { Type } from "class-transformer"
import { EntityManager } from "typeorm"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { ProductStatus } from "../../../../models"
import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant"
import { ProductStatus, ProductVariant } from "../../../../models"
import {
CreateProductVariantInput,
ProductVariantPricesUpdateReq,
} from "../../../../types/product-variant"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { validator } from "../../../../utils/validator"
import { MedusaError } from "medusa-core-utils"
import { DistributedTransaction } from "../../../../utils/transaction"
import {
createVariantTransaction,
revertVariantTransaction,
} from "./transaction/create-product-variant"
import { IInventoryService } from "../../../../interfaces"
import { Logger } from "../../../../types/global"
/**
* @oas [post] /products/{id}
@@ -96,14 +112,106 @@ export default async (req, res) => {
const validated = await validator(AdminPostProductsProductReq, req.body)
const logger: Logger = req.scope.resolve("logger")
const productService: ProductService = req.scope.resolve("productService")
const pricingService: PricingService = req.scope.resolve("pricingService")
const productVariantService: ProductVariantService = req.scope.resolve(
"productVariantService"
)
const productVariantInventoryService: ProductVariantInventoryService =
req.scope.resolve("productVariantInventoryService")
const inventoryService: IInventoryService | undefined =
req.scope.resolve("inventoryService")
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
const { variants } = validated
delete validated.variants
await productService
.withTransaction(transactionManager)
.update(id, validated)
if (!variants) {
return
}
const product = await productService
.withTransaction(transactionManager)
.retrieve(id, {
relations: ["variants"],
})
// Iterate product variants and update their properties accordingly
for (const variant of product.variants) {
const exists = variants.find((v) => v.id && variant.id === v.id)
if (!exists) {
await productVariantService
.withTransaction(transactionManager)
.delete(variant.id)
}
}
const allVariantTransactions: DistributedTransaction[] = []
const transactionDependencies = {
manager: transactionManager,
inventoryService,
productVariantInventoryService,
productVariantService,
}
for (const [index, newVariant] of variants.entries()) {
const variantRank = index
if (newVariant.id) {
const variant = product.variants.find((v) => v.id === newVariant.id)
if (!variant) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Variant with id: ${newVariant.id} is not associated with this product`
)
}
await productVariantService
.withTransaction(transactionManager)
.update(variant, {
...newVariant,
variant_rank: variantRank,
product_id: variant.product_id,
})
} else {
// If the provided variant does not have an id, we assume that it
// should be created
try {
const input = {
...newVariant,
variant_rank: variantRank,
options: newVariant.options || [],
prices: newVariant.prices || [],
}
const varTransation = await createVariantTransaction(
transactionDependencies,
product.id,
input as CreateProductVariantInput
)
allVariantTransactions.push(varTransation)
} catch (e) {
await Promise.all(
allVariantTransactions.map(async (transaction) => {
await revertVariantTransaction(
transactionDependencies,
transaction
).catch(() => logger.warn("Transaction couldn't be reverted."))
})
)
throw e
}
}
}
})
const rawProduct = await productService.retrieve(id, {