feat(types, product): added product module update (#4504)

* Feat: create product with product module

* feat: create product wip

* feat: create product wip

* feat: update product relation and generate image migration

* lint

* conitnue implementation

* continue implementation and add integration tests for produceService.create

* Add integration tests for product creation at the module level for the complete flow

* only use persist since write operations are always wrapped in a transaction which will be committed and flushed

* simplify the transaction wrapper to make future changes easier

* feat: move some utils to the utils package to simplify its usage

* tests: fix unit tests

* feat: create variants along side the product

* Add more integration tests an update migrations

* chore: Update actions workflow to include packages integration tests

* small types and utils cleanup

* chore: Add support for database debug option

* chore: Add missing types in package.json from types and util, validate that all the models are sync with medusa

* expose retrieve method

* fix types issues

* fix unit tests and move integration tests workflow with the plugins integration tests

* chore: remove migration function export from the definition to prevent them to be ran by the medusa cli just in case

* fix package.json script

* chore: workflows

* feat: start creating the create product workflow

* feat: add empty step for prices and sales channel

* tests: update scripts and action envs

* fix imports

* feat: Add proper soft deleted support + add product deletion service public api

* chore: update migrations

* chore: update migrations

* chore: update todo

* feat: Add product deletion to the create-product workflow as compensation

* chore: cleanup product utils

* feat: Add support for cascade soft-remove

* feat: refactor repository to take into account withDeleted

* fix integration tests

* Add support for force delete -> delete, cleanup repositories and improvements

* Add support for restoring a product and add integration tests

* cleaup + tests

* types

* fix integration tests

* remove unnecessary comments

* move specific mikro orm usage to the DAL

* Cleanup workflow functions

* Make deleted_at optional at the property level and add url index for the images

* address feedback + cleanup

* fix export

* merge migrations into one

* feat(product, types): added missing product variant methods (#4475)

* chore: added missing product variant methods

* chore: address PR feedback

* chore: catch undefined case for retrieve + specs for variant service

* chore: align TEntity + add changeset

* chore: revert changeset, TEntity to ProductVariant

* chore: write tests for pagination, unskip the test

* Create chilled-mice-deliver.md

* update integration fixtuers

* update pipeline node version

* rename github action

* fix pipeline

* feat(medusa, types): added missing category tests and service methods (#4499)

* chore: added missing category tests and service methods

* chore: added type changes to module service

* chore: address pr feedback

* chore: added product module update

* chore: use status enum type from common types

* chore: remove flushing at repo level, pass in relation instead of ID

* chore: update error message for missing id

* update repositories manager usage and serialisation from the write public API

* move serializisation to the DAL

* rename template args

* chore: address feedback

* chore: wip

* chore: added collection methods for module and collection service (#4505)

* chore: added collection methods for module and collection service

* Create fresh-islands-teach.md

* chore: move retrieve entity to utils package

* chore: make products optional in DTO type

---------

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* chore: added categories, collections and other relations to update

* feat(product): Apply transaction decorators to the services (#4512)

* chore: handle variant update, create and delete through products update

* chore: cleanup types, self review

* chore: remove relations that are not present in collection

* chore: address reviews p1

* chore: add test for incorrect ID + remove extra check on variant id existance

* chore: cleanup + add changeset

* chore: wip

* chore: add todos for getter method

---------

Co-authored-by: adrien2p <adrien.deperetti@gmail.com>
Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2023-07-26 18:48:35 +05:30
committed by GitHub
parent 585ebf2454
commit caea44ebfd
18 changed files with 1299 additions and 41 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/product": patch
"@medusajs/types": patch
---
feat(product, types): added product module service update

View File

@@ -5,6 +5,7 @@ import {
Product,
ProductCategory,
ProductCollection,
ProductType,
ProductVariant,
} from "@models"
import ProductOption from "../../../src/models/product-option"
@@ -59,6 +60,22 @@ export async function createCollections(
return collections
}
export async function createTypes(
manager: SqlEntityManager,
typesData: {
id?: string
value: string
}[]
) {
const types: any[] = typesData.map((typesData) => {
return manager.create(ProductType, typesData)
})
await manager.persistAndFlush(types)
return types
}
export async function createOptions(
manager: SqlEntityManager,
optionsData: {

View File

@@ -0,0 +1,464 @@
import { MedusaModule } from "@medusajs/modules-sdk"
import { Product, ProductCategory, ProductCollection, ProductType, ProductVariant } from "@models"
import { IProductModuleService, ProductTypes } from "@medusajs/types"
import { initialize } from "../../../../src"
import { DB_URL, TestDatabase } from "../../../utils"
import { buildProductAndRelationsData } from "../../../__fixtures__/product/data/create-product"
import { createProductCategories } from "../../../__fixtures__/product-category"
import { createCollections, createTypes } from "../../../__fixtures__/product"
const beforeEach_ = async () => {
await TestDatabase.setupDatabase()
return await TestDatabase.forkManager()
}
const afterEach_ = async () => {
await TestDatabase.clearDatabase()
}
describe("ProductModuleService products", function () {
describe("update", function () {
let module: IProductModuleService
let productOne: Product
let productTwo: Product
let productCategoryOne: ProductCategory
let productCategoryTwo: ProductCategory
let productCollectionOne: ProductCollection
let productCollectionTwo: ProductCollection
let variantOne: ProductVariant
let variantTwo: ProductVariant
let variantThree: ProductVariant
let productTypeOne: ProductType
let productTypeTwo: ProductType
let images = ["image-1"]
const productCategoriesData = [{
id: "test-1",
name: "category 1",
}, {
id: "test-2",
name: "category 2",
}]
const productCollectionsData = [
{
id: "test-1",
title: "col 1",
},
{
id: "test-2",
title: "col 2",
},
]
const productTypesData = [
{
id: "type-1",
value: "type 1",
},
{
id: "type-2",
value: "type 2",
},
]
const tagsData = [{
id: "tag-1",
value: "tag 1",
}]
beforeEach(async () => {
const testManager = await beforeEach_()
const collections = await createCollections(
testManager,
productCollectionsData
)
productCollectionOne = collections[0]
productCollectionTwo = collections[1]
const types = await createTypes(
testManager,
productTypesData,
)
productTypeOne = types[0]
productTypeTwo = types[1]
const categories = (await createProductCategories(
testManager,
productCategoriesData
))
productCategoryOne = categories[0]
productCategoryTwo = categories[1]
productOne = testManager.create(Product, {
id: "product-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
})
productTwo = testManager.create(Product, {
id: "product-2",
title: "product 2",
status: ProductTypes.ProductStatus.PUBLISHED,
categories: [productCategoryOne],
collection_id: productCollectionOne.id,
tags: tagsData,
})
variantOne = testManager.create(ProductVariant, {
id: "variant-1",
title: "variant 1",
inventory_quantity: 10,
product: productOne,
})
variantTwo = testManager.create(ProductVariant, {
id: "variant-2",
title: "variant 2",
inventory_quantity: 10,
product: productTwo,
})
variantThree = testManager.create(ProductVariant, {
id: "variant-3",
title: "variant 3",
inventory_quantity: 10,
product: productTwo,
})
await testManager.persistAndFlush([productOne, productTwo])
MedusaModule.clearInstances()
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
})
afterEach(afterEach_)
it("should update a product and upsert relations that are not created yet", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
})
const updateData = {
...data,
id: productOne.id,
title: "updated title"
}
const updatedProducts = await module.update([updateData])
expect(updatedProducts).toHaveLength(1)
const product = await module.retrieve(updateData.id, {
relations: ["images", "variants", "options", "options.values", "variants.options", "tags", "type",]
})
expect(product.images).toHaveLength(1)
expect(product.variants[0].options).toHaveLength(1)
expect(product.tags).toHaveLength(1)
expect(product.variants).toHaveLength(1)
expect(product).toEqual(
expect.objectContaining({
id: expect.any(String),
title: "updated title",
description: updateData.description,
subtitle: updateData.subtitle,
is_giftcard: updateData.is_giftcard,
discountable: updateData.discountable,
thumbnail: images[0],
status: updateData.status,
images: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
url: images[0],
}),
]),
options: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
title: updateData.options[0].title,
values: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: updateData.variants[0].options?.[0].value,
}),
]),
}),
]),
tags: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: updateData.tags[0].value,
}),
]),
type: expect.objectContaining({
id: expect.any(String),
value: updateData.type.value,
}),
variants: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
title: updateData.variants[0].title,
sku: updateData.variants[0].sku,
allow_backorder: false,
manage_inventory: true,
inventory_quantity: "100",
variant_rank: "0",
options: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: updateData.variants[0].options?.[0].value,
}),
]),
}),
]),
})
)
})
it("should add relationships to a product", async () => {
const updateData = {
id: productOne.id,
categories: [{
id: productCategoryOne.id
}],
collection_id: productCollectionOne.id,
type_id: productTypeOne.id
}
await module.update([updateData])
const product = await module.retrieve(updateData.id, {
relations: ["categories", "collection", "type"]
})
expect(product).toEqual(
expect.objectContaining({
id: productOne.id,
categories: [
expect.objectContaining({
id: productCategoryOne.id
})
],
collection: expect.objectContaining({
id: productCollectionOne.id
}),
type: expect.objectContaining({
id: productTypeOne.id
})
})
)
})
it("should upsert a product type when type object is passed", async () => {
let updateData = {
id: productTwo.id,
type: {
id: productTypeOne.id,
value: productTypeOne.value
}
}
await module.update([updateData])
let product = await module.retrieve(updateData.id, {
relations: ["type"]
})
expect(product).toEqual(
expect.objectContaining({
id: productTwo.id,
type: expect.objectContaining({
id: productTypeOne.id
})
})
)
updateData = {
id: productTwo.id,
type: {
id: "new-type-id",
value: "new-type-value"
}
}
await module.update([updateData])
product = await module.retrieve(updateData.id, {
relations: ["type"]
})
expect(product).toEqual(
expect.objectContaining({
id: productTwo.id,
type: expect.objectContaining({
...updateData.type
})
})
)
})
it("should replace relationships of a product", async () => {
const newTagData = {
id: "tag-2",
value: "tag 2",
}
const updateData = {
id: productTwo.id,
categories: [{
id: productCategoryTwo.id
}],
collection_id: productCollectionTwo.id,
type_id: productTypeTwo.id,
tags: [newTagData],
}
await module.update([updateData])
const product = await module.retrieve(updateData.id, {
relations: ["categories", "collection", "tags", "type"]
})
expect(product).toEqual(
expect.objectContaining({
id: productTwo.id,
categories: [
expect.objectContaining({
id: productCategoryTwo.id
})
],
collection: expect.objectContaining({
id: productCollectionTwo.id
}),
tags: [
expect.objectContaining({
id: newTagData.id,
value: newTagData.value
})
],
type: expect.objectContaining({
id: productTypeTwo.id
})
})
)
})
it("should remove relationships of a product", async () => {
const updateData = {
id: productTwo.id,
categories: [],
collection_id: null,
type_id: null,
tags: []
}
await module.update([updateData])
const product = await module.retrieve(updateData.id, {
relations: ["categories", "collection", "tags"]
})
expect(product).toEqual(
expect.objectContaining({
id: productTwo.id,
categories: [],
tags: [],
collection: null,
type: null
})
)
})
it("should throw an error when product ID does not exist", async () => {
let error
const updateData = {
id: "does-not-exist",
title: "test"
}
try {
await module.update([updateData])
} catch (e) {
error = e.message
}
expect(error).toEqual(`Product with id "does-not-exist" not found`)
})
it("should update, create and delete variants", async () => {
const updateData = {
id: productTwo.id,
// Note: VariantThree is already assigned to productTwo, that should be deleted
variants: [{
id: variantTwo.id,
title: "updated-variant"
}, {
title: "created-variant"
}]
}
await module.update([updateData])
const product = await module.retrieve(updateData.id, {
relations: ["variants"]
})
expect(product.variants).toHaveLength(2)
expect(product).toEqual(
expect.objectContaining({
id: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
id: variantTwo.id,
title: "updated-variant",
}),
expect.objectContaining({
id: expect.any(String),
title: "created-variant",
}),
]),
})
)
})
it("should throw an error when variant with id does not exist", async () => {
let error
const updateData = {
id: productTwo.id,
// Note: VariantThree is already assigned to productTwo, that should be deleted
variants: [{
id: "does-not-exist",
title: "updated-variant"
}, {
title: "created-variant"
}]
}
try {
await module.update([updateData])
} catch (e) {
error = e
}
await module.retrieve(updateData.id, {
relations: ["variants"]
})
expect(error.message).toEqual(`ProductVariant with id "does-not-exist" not found`)
})
})
})

View File

@@ -11,7 +11,7 @@ import {
variantsData,
} from "../../../__fixtures__/product/data"
import { ProductDTO } from "@medusajs/types"
import { ProductDTO, ProductTypes } from "@medusajs/types"
import { ProductRepository } from "@repositories"
import { ProductService } from "@services"
import { SqlEntityManager } from "@mikro-orm/postgresql"
@@ -27,6 +27,7 @@ describe("Product Service", () => {
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let products!: Product[]
let productOne: Product
let variants!: ProductVariant[]
let categories!: ProductCategory[]
@@ -47,6 +48,51 @@ describe("Product Service", () => {
await TestDatabase.clearDatabase()
})
describe("retrieve", () => {
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
productOne = testManager.create(Product, {
id: "product-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
})
await testManager.persistAndFlush([productOne])
})
it("should throw an error when an id is not provided", async () => {
let error
try {
await service.retrieve(undefined as unknown as string)
} catch (e) {
error = e
}
expect(error.message).toEqual('"productId" must be defined')
})
it("should throw an error when product with id does not exist", async () => {
let error
try {
await service.retrieve("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual('Product with id: does-not-exist was not found')
})
it("should return a product when product with an id exists", async () => {
const result = await service.retrieve(productOne.id)
expect(result).toEqual(expect.objectContaining({
id: productOne.id
}))
})
})
describe("create", function () {
let images: Image[] = []
@@ -87,6 +133,94 @@ describe("Product Service", () => {
})
})
describe("update", function () {
let images: Image[] = []
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
images = await createImages(testManager, ["image-1", "image-2"])
productOne = testManager.create(Product, {
id: "product-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
})
await testManager.persistAndFlush([productOne])
})
it("should update a product and its allowed relations", async () => {
const updateData = [{
id: productOne.id,
title: "update test 1",
images: images,
thumbnail: images[0].url,
}]
const products = await service.update(updateData)
expect(products.length).toEqual(1)
let result = await service.retrieve(productOne.id, {relations: ["images", "thumbnail"]})
let serialized = JSON.parse(JSON.stringify(result))
expect(serialized).toEqual(
expect.objectContaining({
id: productOne.id,
title: "update test 1",
thumbnail: images[0].url,
images: [
expect.objectContaining({
url: images[0].url,
}),
expect.objectContaining({
url: images[1].url,
}),
],
})
)
})
it("should throw an error when id is not present", async () => {
let error
const updateData = [{
id: productOne.id,
title: "update test 1",
}, {
id: undefined as unknown as string,
title: "update test 2",
}]
try {
await service.update(updateData)
} catch (e) {
error = e
}
expect(error.message).toEqual(`Product with id "undefined" not found`)
let result = await service.retrieve(productOne.id)
expect(result.title).not.toBe("update test 1")
})
it("should throw an error when product with id does not exist", async () => {
let error
const updateData = [{
id: "does-not-exist",
title: "update test 1",
}]
try {
await service.update(updateData)
} catch (e) {
error = e
}
expect(error.message).toEqual(`Product with id "does-not-exist" not found`)
})
})
describe("list", () => {
describe("soft deleted", function () {
let deletedProduct

View File

@@ -107,7 +107,7 @@ class Product {
nullable: true,
fieldName: "collection_id",
})
collection!: ProductCollection
collection!: ProductCollection | null
@Property({ columnType: "text", nullable: true })
type_id!: string

View File

@@ -4,11 +4,18 @@ import {
LoadStrategy,
RequiredEntityData,
} from "@mikro-orm/core"
import { Product, ProductVariant } from "@models"
import { Context, DAL } from "@medusajs/types"
import { AbstractBaseRepository } from "./base"
import { ProductVariant } from "@models"
import { Context, DAL, WithRequiredProperty } from "@medusajs/types"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
import {
MedusaError,
isDefined,
InjectTransactionManager,
MedusaContext,
} from "@medusajs/utils"
import { ProductVariantServiceTypes } from "../types/services"
import { AbstractBaseRepository } from "./base"
import { doNotForceTransaction } from "../utils"
export class ProductVariantRepository extends AbstractBaseRepository<ProductVariant> {
@@ -69,7 +76,7 @@ export class ProductVariantRepository extends AbstractBaseRepository<ProductVari
{ transactionManager: manager }: Context = {}
): Promise<void> {
await (manager as SqlEntityManager).nativeDelete(
Product,
ProductVariant,
{ id: { $in: ids } },
{}
)
@@ -89,4 +96,37 @@ export class ProductVariantRepository extends AbstractBaseRepository<ProductVari
return variants
}
async update(
data: WithRequiredProperty<ProductVariantServiceTypes.UpdateProductVariantDTO, "id">[],
context: Context = {}
): Promise<ProductVariant[]> {
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
const productVariantsToUpdate = await manager.find(ProductVariant, {
id: data.map((updateData) => updateData.id)
})
const productVariantsToUpdateMap = new Map<string, ProductVariant>(
productVariantsToUpdate.map((variant) => [variant.id, variant])
)
const variants = data.map((variantData) => {
const productVariant = productVariantsToUpdateMap.get(variantData.id)
if (!productVariant) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`ProductVariant with id "${variantData.id}" not found`
)
}
return manager.assign(productVariant, variantData)
})
await manager.persist(variants)
return variants
}
}

View File

@@ -1,18 +1,29 @@
import { Product } from "@models"
import {
Product,
ProductCategory,
ProductCollection,
ProductType,
ProductTag,
} from "@models"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
LoadStrategy,
wrap
} from "@mikro-orm/core"
import {
Context,
DAL,
ProductTypes,
WithRequiredProperty,
} from "@medusajs/types"
import { AbstractBaseRepository } from "./base"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { InjectTransactionManager, MedusaContext } from "@medusajs/utils"
import { MedusaError, isDefined, InjectTransactionManager, MedusaContext } from "@medusajs/utils"
import { AbstractBaseRepository } from "./base"
import { ProductServiceTypes } from "../types/services"
export class ProductRepository extends AbstractBaseRepository<Product> {
protected readonly manager_: SqlEntityManager
@@ -27,6 +38,7 @@ export class ProductRepository extends AbstractBaseRepository<Product> {
findOptions: DAL.FindOptions<Product> = { where: {} },
context: Context = {}
): Promise<Product[]> {
// TODO: use the getter method (getActiveManager)
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
@@ -78,6 +90,7 @@ export class ProductRepository extends AbstractBaseRepository<Product> {
findOptions: DAL.FindOptions<Product> = { where: {} },
context: Context = {}
): Promise<void> {
// TODO: use the getter method (getActiveManager)
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
@@ -134,4 +147,165 @@ export class ProductRepository extends AbstractBaseRepository<Product> {
return products
}
@InjectTransactionManager()
async update(
data: WithRequiredProperty<ProductServiceTypes.UpdateProductDTO, "id">[],
@MedusaContext() context: Context = {}
): Promise<Product[]> {
let categoryIds: string[] = []
let tagIds: string[] = []
let collectionIds: string[] = []
let typeIds: string[] = []
// TODO: use the getter method (getActiveManager)
const manager = (context.transactionManager ??
this.manager_) as SqlEntityManager
data.forEach((productData) => {
categoryIds = categoryIds.concat(
productData?.categories?.map(c => c.id) || []
)
tagIds = tagIds.concat(
productData?.tags?.map(c => c.id) || []
)
if (productData.collection_id) {
collectionIds.push(productData.collection_id)
}
if (productData.type_id) {
typeIds.push(productData.type_id)
}
})
const productsToUpdate = await manager.find(Product, {
id: data.map((updateData) => updateData.id)
}, {
populate: ["tags", "categories"]
})
const collectionsToAssign = collectionIds.length ? await manager.find(ProductCollection, {
id: collectionIds
}) : []
const typesToAssign = typeIds.length ? await manager.find(ProductType, {
id: typeIds
}) : []
const categoriesToAssign = categoryIds.length ? await manager.find(ProductCategory, {
id: categoryIds
}) : []
const tagsToAssign = tagIds.length ? await manager.find(ProductTag, {
id: tagIds
}) : []
const categoriesToAssignMap = new Map<string, ProductCategory>(
categoriesToAssign.map((category) => [category.id, category])
)
const tagsToAssignMap = new Map<string, ProductTag>(
tagsToAssign.map((tag) => [tag.id, tag])
)
const collectionsToAssignMap = new Map<string, ProductCollection>(
collectionsToAssign.map((collection) => [collection.id, collection])
)
const typesToAssignMap = new Map<string, ProductType>(
typesToAssign.map((type) => [type.id, type])
)
const productsToUpdateMap = new Map<string, Product>(
productsToUpdate.map((product) => [product.id, product])
)
const products = await Promise.all(
data.map(async (updateData) => {
const product = productsToUpdateMap.get(updateData.id)
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with id "${updateData.id}" not found`
)
}
const {
categories: categoriesData,
tags: tagsData,
collection_id: collectionId,
type_id: typeId,
} = updateData
delete updateData?.categories
delete updateData?.tags
delete updateData?.collection_id
delete updateData?.type_id
if (isDefined(categoriesData)) {
await product.categories.init()
for (const categoryData of categoriesData) {
const productCategory = categoriesToAssignMap.get(categoryData.id)
if (productCategory) {
await product.categories.add(productCategory)
}
}
const categoryIdsToAssignSet = new Set(categoriesData.map(cd => cd.id))
const categoriesToDelete = product.categories.getItems().filter(
(existingCategory) => !categoryIdsToAssignSet.has(existingCategory.id)
)
await product.categories.remove(categoriesToDelete)
}
if (isDefined(tagsData)) {
await product.tags.init()
for (const tagData of tagsData) {
let productTag = tagsToAssignMap.get(tagData.id)
if (tagData instanceof ProductTag) {
productTag = tagData
}
if (productTag) {
await product.tags.add(productTag)
}
}
const tagIdsToAssignSet = new Set(tagsData.map(cd => cd.id))
const tagsToDelete = product.tags.getItems().filter(
(existingTag) => !tagIdsToAssignSet.has(existingTag.id)
)
await product.tags.remove(tagsToDelete)
}
if (isDefined(collectionId)) {
const collection = collectionsToAssignMap.get(collectionId)
product.collection = collection || null
}
if (isDefined(typeId)) {
const type = typesToAssignMap.get(typeId)
if (type) {
product.type = type
}
}
return manager.assign(product, updateData)
})
)
await manager.persist(products)
return products
}
}

View File

@@ -26,13 +26,17 @@ import {
JoinerServiceConfig,
ProductTypes,
} from "@medusajs/types"
import { serialize } from "@mikro-orm/core"
import ProductImageService from "./product-image"
import { ProductServiceTypes, ProductVariantServiceTypes } from "../types/services"
import {
InjectTransactionManager,
isDefined,
isString,
kebabCase,
MedusaContext,
MedusaError,
} from "@medusajs/utils"
import { shouldForceTransaction } from "../utils"
import { joinerConfig } from "./../joiner-config"
@@ -118,10 +122,12 @@ export default class ProductModuleService<
async retrieve(
productId: string,
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: Context
): Promise<ProductTypes.ProductDTO> {
const product = await this.productService_.retrieve(
productId,
config,
sharedContext
)
@@ -282,7 +288,7 @@ export default class ProductModuleService<
return JSON.parse(JSON.stringify(categories))
}
async create(data: ProductTypes.CreateProductDTO[], sharedContext?: Context) {
async create(data: ProductTypes.CreateProductDTO[], sharedContext?: Context): Promise<ProductTypes.ProductDTO[]> {
const products = await this.create_(data, sharedContext)
return this.baseRepository_.serialize<ProductTypes.ProductDTO[]>(products, {
@@ -290,6 +296,19 @@ export default class ProductModuleService<
})
}
async update(
data: ProductTypes.UpdateProductDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.update_(data, sharedContext)
return this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products, {
populate: true,
})
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
protected async create_(
data: ProductTypes.CreateProductDTO[],
@@ -329,30 +348,9 @@ export default class ProductModuleService<
productData.discountable = false
}
if (productData.images?.length) {
productData.images = await this.productImageService_.upsert(
productData.images.map((image) =>
isString(image) ? image : image.url
),
sharedContext
)
}
if (productData.tags?.length) {
productData.tags = await this.productTagService_.upsert(
productData.tags,
sharedContext
)
}
if (isDefined(productData.type)) {
productData.type = (
await this.productTypeService_.upsert(
[productData.type as ProductTypes.CreateProductTypeDTO],
sharedContext
)
)?.[0]!
}
await this.upsertAndAssignImagesToProductData(productData, sharedContext)
await this.upsertAndAssignProductTagsToProductData(productData, sharedContext)
await this.upsertAndAssignProductTypeToProductData(productData, sharedContext)
return productData as CreateProductOnlyDTO
})
@@ -408,6 +406,223 @@ export default class ProductModuleService<
return products
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
protected async update_(
data: ProductTypes.UpdateProductDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
const productIds = data.map(pd => pd.id)
const existingProductVariants = await this.productVariantService_.list(
{ product_id: productIds },
{},
sharedContext
)
const existingProductVariantsMap = new Map<
string,
ProductVariant[]
>(
data.map((productData) => {
const productVariantsForProduct = existingProductVariants
.filter((variant) => variant.product_id === productData.id)
return [
productData.id,
productVariantsForProduct,
]
})
)
const productVariantsMap = new Map<
string,
(ProductTypes.CreateProductVariantDTO | ProductTypes.UpdateProductVariantDTO)[]
>()
const productOptionsMap = new Map<
string,
ProductTypes.CreateProductOptionDTO[]
>()
const productsData = await Promise.all(
data.map(async (product) => {
const { variants, options, ...productData } = product
if (!isDefined(productData.id)) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Cannot update product without id`
)
}
productVariantsMap.set(productData.id, variants ?? [])
productOptionsMap.set(productData.id, options ?? [])
if (productData.is_giftcard) {
productData.discountable = false
}
await this.upsertAndAssignImagesToProductData(productData, sharedContext)
await this.upsertAndAssignProductTagsToProductData(productData, sharedContext)
await this.upsertAndAssignProductTypeToProductData(productData, sharedContext)
return productData as ProductServiceTypes.UpdateProductDTO
})
)
const products = await this.productService_.update(
productsData,
sharedContext
)
const productByIdMap = new Map<string, TProduct>(
products.map((product) => [product.id, product])
)
const productOptionsData = [...productOptionsMap]
.map(([id, options]) => options.map((option) => ({
...option,
product: productByIdMap.get(id)!,
})))
.flat()
const productOptions = await this.productOptionService_.create(
productOptionsData,
sharedContext
)
const productVariantIdsToDelete: string[] = []
const productVariantsToCreateMap = new Map<
string,
ProductTypes.CreateProductVariantDTO[]
>()
const productVariantsToUpdateMap = new Map<
string,
ProductTypes.UpdateProductVariantDTO[]
>()
for (const [productId, variants] of productVariantsMap) {
const variantsToCreate: ProductTypes.CreateProductVariantDTO[] = []
const variantsToUpdate: ProductTypes.UpdateProductVariantDTO[] = []
const existingVariants = existingProductVariantsMap.get(productId)
variants.forEach((variant) => {
const isVariantIdDefined = ("id" in variant) && isDefined(variant.id)
if (isVariantIdDefined) {
variantsToUpdate.push(variant as ProductTypes.UpdateProductVariantDTO)
} else {
variantsToCreate.push(variant as ProductTypes.CreateProductVariantDTO)
}
const variantOptions = variant.options?.map((option, index) => {
const productOption = productOptions[index]
return {
option: productOption,
value: option.value,
}
})
if (variantOptions) {
variant.options = variantOptions
}
})
productVariantsToCreateMap.set(productId, variantsToCreate)
productVariantsToUpdateMap.set(productId, variantsToUpdate)
const variantsToUpdateIds = variantsToUpdate.map(v => v?.id) as string[]
const existingVariantIds = existingVariants?.map(v => v.id) || []
const variantsToUpdateSet = new Set(variantsToUpdateIds)
productVariantIdsToDelete.push(
...new Set(
existingVariantIds.filter(x => !variantsToUpdateSet.has(x))
)
)
}
const promises: Promise<any>[] = []
productVariantsToCreateMap.forEach((variants, productId) => {
promises.push(
this.productVariantService_.create(
productByIdMap.get(productId)!,
variants as unknown as ProductTypes.CreateProductVariantOnlyDTO[],
sharedContext
)
)
})
productVariantsToUpdateMap.forEach((variants, productId) => {
promises.push(
this.productVariantService_.update(
productByIdMap.get(productId)!,
variants as unknown as ProductVariantServiceTypes.UpdateProductVariantDTO[],
sharedContext
)
)
})
if (productVariantIdsToDelete.length) {
promises.push(
this.productVariantService_.delete(productVariantIdsToDelete, sharedContext)
)
}
await Promise.all(promises)
return products
}
protected async upsertAndAssignImagesToProductData(
productData: ProductTypes.CreateProductDTO | ProductTypes.UpdateProductDTO,
sharedContext: Context = {}
) {
if (!productData.thumbnail && productData.images?.length) {
productData.thumbnail = isString(productData.images[0])
? (productData.images[0] as string)
: (productData.images[0] as { url: string }).url
}
if (productData.images?.length) {
productData.images = await this.productImageService_.upsert(
productData.images.map((image) =>
isString(image) ? image : image.url
),
sharedContext
)
}
}
protected async upsertAndAssignProductTagsToProductData(
productData: ProductTypes.CreateProductDTO | ProductTypes.UpdateProductDTO,
sharedContext: Context = {}
) {
if (productData.tags?.length) {
productData.tags = await this.productTagService_.upsert(
productData.tags,
sharedContext
)
}
}
protected async upsertAndAssignProductTypeToProductData(
productData: ProductTypes.CreateProductDTO | ProductTypes.UpdateProductDTO,
sharedContext: Context = {}
) {
if (isDefined(productData.type)) {
const productType = (
await this.productTypeService_.upsert(
[productData.type as ProductTypes.CreateProductTypeDTO],
sharedContext
)
)
productData.type = productType?.[0]
}
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async delete(
productIds: string[],

View File

@@ -1,5 +1,6 @@
import { Product, ProductVariant } from "@models"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { ProductVariantRepository } from "@repositories"
import {
InjectTransactionManager,
isString,
@@ -8,9 +9,9 @@ import {
retrieveEntity,
} from "@medusajs/utils"
import { ProductVariantServiceTypes } from "../types/services"
import ProductService from "./product"
import { doNotForceTransaction } from "../utils"
import { ProductVariantRepository } from "@repositories"
type InjectedDependencies = {
productVariantRepository: DAL.RepositoryService
@@ -91,7 +92,8 @@ export default class ProductVariantService<
if (isString(productOrId)) {
product = await this.productService_.retrieve(
productOrId as string,
productOrId,
{},
sharedContext
)
}
@@ -112,4 +114,38 @@ export default class ProductVariantService<
transactionManager: sharedContext.transactionManager,
})) as TEntity[]
}
@InjectTransactionManager(doNotForceTransaction, "productVariantRepository_")
async update(
productOrId: TProduct | string,
data: ProductVariantServiceTypes.UpdateProductVariantDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
let product = productOrId as unknown as Product
if (isString(productOrId)) {
product = await this.productService_.retrieve(
productOrId,
{},
sharedContext
)
}
const variantsData = [...data]
variantsData.forEach((variant) => Object.assign(variant, { product }))
return await (this.productVariantRepository_ as ProductVariantRepository).update(variantsData, {
transactionManager: sharedContext.transactionManager,
}) as TEntity[]
}
@InjectTransactionManager(doNotForceTransaction, "productVariantRepository_")
async delete(
ids: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
return await this.productVariantRepository_.delete(ids, {
transactionManager: sharedContext.transactionManager,
})
}
}

View File

@@ -12,8 +12,11 @@ import {
MedusaContext,
MedusaError,
ModulesSdkUtils,
isDefined,
} from "@medusajs/utils"
import { ProductRepository } from "@repositories"
import { ProductServiceTypes } from "../types/services"
import { doNotForceTransaction } from "../utils"
type InjectedDependencies = {
@@ -27,10 +30,22 @@ export default class ProductService<TEntity extends Product = Product> {
this.productRepository_ = productRepository
}
async retrieve(productId: string, sharedContext?: Context): Promise<TEntity> {
async retrieve(
productId: string,
config: FindConfig<ProductTypes.ProductDTO> = {},
sharedContext?: Context
): Promise<TEntity> {
if (!isDefined(productId)) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`"productId" must be defined`
)
}
const queryOptions = ModulesSdkUtils.buildQuery<Product>({
id: productId,
})
}, config)
const product = await this.productRepository_.find(
queryOptions,
sharedContext
@@ -116,6 +131,22 @@ export default class ProductService<TEntity extends Product = Product> {
)) as TEntity[]
}
@InjectTransactionManager(doNotForceTransaction, "productRepository_")
async update(
data: ProductServiceTypes.UpdateProductDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return await (this.productRepository_ as ProductRepository).update(
data as WithRequiredProperty<
ProductServiceTypes.UpdateProductDTO,
"id"
>[],
{
transactionManager: sharedContext.transactionManager,
}
) as TEntity[]
}
@InjectTransactionManager(doNotForceTransaction, "productRepository_")
async delete(
ids: string[],

View File

@@ -3,3 +3,5 @@ import { IEventBusService } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
eventBusService?: IEventBusService
}
export * from "./services"

View File

@@ -0,0 +1,2 @@
export * as ProductServiceTypes from "./product"
export * as ProductVariantServiceTypes from "./product-variant"

View File

@@ -0,0 +1,23 @@
import { CreateProductVariantOptionDTO } from "@medusajs/types"
export interface UpdateProductVariantDTO {
id: string
title?: string
sku?: string
barcode?: string
ean?: string
upc?: string
allow_backorder?: boolean
inventory_quantity?: number
manage_inventory?: boolean
hs_code?: string
origin_country?: string
mid_code?: string
material?: string
weight?: number
length?: number
height?: number
width?: number
options?: CreateProductVariantOptionDTO[]
metadata?: Record<string, unknown>
}

View File

@@ -0,0 +1,27 @@
import { ProductStatus, ProductCategoryDTO } from "@medusajs/types"
export interface UpdateProductDTO {
id: string
title?: string
subtitle?: string
description?: string
is_giftcard?: boolean
discountable?: boolean
images?: { id?: string; url: string }[]
thumbnail?: string
handle?: string
status?: ProductStatus
collection_id?: string
width?: number
height?: number
length?: number
weight?: number
origin_country?: string
hs_code?: string
material?: string
mid_code?: string
metadata?: Record<string, unknown>
tags?: { id: string }[]
categories?: { id: string }[]
type_id?: string
}

View File

@@ -34,6 +34,9 @@ export interface RepositoryService<T = any> {
create(data: unknown[], context?: Context): Promise<T[]>
// TODO: remove optionality when all the other repositories have an update
update?(data: unknown[], context?: Context): Promise<T[]>
delete(ids: string[], context?: Context): Promise<void>
softDelete(ids: string[], context?: Context): Promise<T[]>

View File

@@ -58,6 +58,7 @@ export interface IInventoryService {
context?: SharedContext
): Promise<ReservationItemDTO>
// TODO make it bulk
createReservationItems(
input: CreateReservationItemInput[],
context?: SharedContext

View File

@@ -163,6 +163,7 @@ export interface FilterableProductVariantProps
extends BaseFilterable<FilterableProductVariantProps> {
id?: string | string[]
sku?: string | string[]
product_id?: string | string[]
options?: { id?: string[] }
}
@@ -219,6 +220,28 @@ export interface CreateProductVariantDTO {
metadata?: Record<string, unknown>
}
export interface UpdateProductVariantDTO {
id: string
title?: string
sku?: string
barcode?: string
ean?: string
upc?: string
allow_backorder?: boolean
inventory_quantity?: number
manage_inventory?: boolean
hs_code?: string
origin_country?: string
mid_code?: string
material?: string
weight?: number
length?: number
height?: number
width?: number
options?: CreateProductVariantOptionDTO[]
metadata?: Record<string, unknown>
}
export interface CreateProductDTO {
title: string
subtitle?: string
@@ -233,7 +256,6 @@ export interface CreateProductDTO {
type_id?: string
collection_id?: string
tags?: CreateProductTagDTO[]
// sales_channel
categories?: { id: string }[]
options?: CreateProductOptionDTO[]
variants?: CreateProductVariantDTO[]
@@ -248,6 +270,35 @@ export interface CreateProductDTO {
metadata?: Record<string, unknown>
}
export interface UpdateProductDTO {
id: string
title?: string
subtitle?: string
description?: string
is_giftcard?: boolean
discountable?: boolean
images?: string[] | { id?: string; url: string }[]
thumbnail?: string
handle?: string
status?: ProductStatus
type?: CreateProductTypeDTO
type_id?: string | null
collection_id?: string | null
tags?: CreateProductTagDTO[]
categories?: { id: string }[]
options?: CreateProductOptionDTO[]
variants?: (CreateProductVariantDTO | UpdateProductVariantDTO)[]
width?: number
height?: number
length?: number
weight?: number
origin_country?: string
hs_code?: string
material?: string
mid_code?: string
metadata?: Record<string, unknown>
}
export interface CreateProductOnlyDTO {
title: string
subtitle?: string
@@ -294,6 +345,28 @@ export interface CreateProductVariantOnlyDTO {
metadata?: Record<string, unknown>
}
export interface UpdateProductVariantOnlyDTO {
id: string,
title?: string
sku?: string
barcode?: string
ean?: string
upc?: string
allow_backorder?: boolean
inventory_quantity?: number
manage_inventory?: boolean
hs_code?: string
origin_country?: string
mid_code?: string
material?: string
weight?: number
length?: number
height?: number
width?: number
options?: (CreateProductVariantOptionDTO & { option: any })[]
metadata?: Record<string, unknown>
}
export interface CreateProductOptionOnlyDTO {
product: { id: string }
title: string

View File

@@ -1,5 +1,6 @@
import {
CreateProductDTO,
UpdateProductDTO,
FilterableProductCategoryProps,
FilterableProductCollectionProps,
FilterableProductProps,
@@ -19,7 +20,11 @@ import { JoinerServiceConfig } from "../joiner"
export interface IProductModuleService {
__joinerConfig(): JoinerServiceConfig
retrieve(productId: string, sharedContext?: Context): Promise<ProductDTO>
retrieve(
productId: string,
config?: FindConfig<ProductDTO>,
sharedContext?: Context
): Promise<ProductDTO>
list(
filters?: FilterableProductProps,
@@ -98,6 +103,11 @@ export interface IProductModuleService {
sharedContext?: Context
): Promise<ProductDTO[]>
update(
data: UpdateProductDTO[],
sharedContext?: Context
): Promise<ProductDTO[]>
delete(productIds: string[], sharedContext?: Context): Promise<void>
softDelete(