feat: Refactor the product module definitions and implementation (#6866)

There are several things done in this PR, namely:

Unify the service endpoints API to always work with a model rather than allowing to pass both ID and model (eg. both type_id and type being available in the request to create).
Start using upsertWithReplace to simplify the code and fix some deassociation bugs
Apply some changes to tests to deal with the pricing breaking changes
Correctly define the model relationships (with both ID and entity fields available)
All tests for the product are passing, which should bring us back to a great baseline.
This commit is contained in:
Stevche Radevski
2024-03-29 10:03:41 +01:00
committed by GitHub
parent e603726985
commit cbb5e6bd99
35 changed files with 1318 additions and 1374 deletions

View File

@@ -32,6 +32,50 @@ let {
jest.setTimeout(50000)
const getProductFixture = () => ({
title: "Test fixture",
description: "test-product-description",
images: breaking(
() => ["test-image.png", "test-image-2.png"],
() => [{ url: "test-image.png" }, { url: "test-image-2.png" }]
),
tags: [{ value: "123" }, { value: "456" }],
options: breaking(
() => [{ title: "size" }, { title: "color" }],
() => [
{ title: "size", values: ["large"] },
{ title: "color", values: ["green"] },
]
),
variants: [
{
title: "Test variant",
inventory_quantity: 10,
prices: [
{
currency_code: "usd",
amount: 100,
},
{
currency_code: "eur",
amount: 45,
},
{
currency_code: "dkk",
amount: 30,
},
],
options: breaking(
() => [{ value: "large" }, { value: "green" }],
() => ({
size: "large",
color: "green",
})
),
},
],
})
medusaIntegrationTestRunner({
env: { MEDUSA_FF_PRODUCT_CATEGORIES: true },
testSuite: ({ dbConnection, getContainer, api }) => {
@@ -41,7 +85,6 @@ medusaIntegrationTestRunner({
let scService
let remoteLink
let container
let productFixture
beforeAll(() => {
// Note: We have to lazily load everything because there are weird ordering issues when doing `require` of `@medusajs/medusa`
@@ -69,54 +112,12 @@ medusaIntegrationTestRunner({
container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
productFixture = {
title: "Test fixture",
description: "test-product-description",
type: { value: "test-type" },
images: ["test-image.png", "test-image-2.png"],
tags: [{ value: "123" }, { value: "456" }],
options: breaking(
() => [{ title: "size" }, { title: "color" }],
() => [
{ title: "size", values: ["large"] },
{ title: "color", values: ["green"] },
]
),
variants: [
{
title: "Test variant",
inventory_quantity: 10,
prices: [
{
currency_code: "usd",
amount: 100,
},
{
currency_code: "eur",
amount: 45,
},
{
currency_code: "dkk",
amount: 30,
},
],
options: breaking(
() => [{ value: "large" }, { value: "green" }],
() => ({
size: "large",
color: "green",
})
),
},
],
}
// We want to seed another product for v2 that has pricing correctly wired up for all pricing-related tests.
v2Product = (
await breaking(
async () => ({}),
async () =>
await api.post("/admin/products", productFixture, adminHeaders)
await api.post("/admin/products", getProductFixture(), adminHeaders)
)
)?.data?.product
@@ -475,13 +476,13 @@ medusaIntegrationTestRunner({
expect.objectContaining({
id: breaking(
() => "test-price_4",
() => expect.stringMatching(/^ma_*/)
() => expect.stringMatching(/^price_*/)
),
}),
expect.objectContaining({
id: breaking(
() => "test-price_3",
() => expect.stringMatching(/^ma_*/)
() => expect.stringMatching(/^price_*/)
),
}),
])
@@ -1398,7 +1399,11 @@ medusaIntegrationTestRunner({
.post(
"/admin/products",
{
...productFixture,
...getProductFixture(),
...breaking(
() => ({ type: { value: "test-type" } }),
() => ({ type_id: "test-type" })
),
title: "Test create",
collection_id: "test-collection",
},
@@ -1408,6 +1413,11 @@ medusaIntegrationTestRunner({
console.log(err)
})
const priceIdSelector = breaking(
() => /^ma_*/,
() => /^price_*/
)
// TODO: It seems we end up with this recursive nested population (product -> variant -> product) that we need to get rid of
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
@@ -1502,7 +1512,7 @@ medusaIntegrationTestRunner({
title: "Test variant",
prices: expect.arrayContaining([
expect.objectContaining({
id: expect.stringMatching(/^ma_*/),
id: expect.stringMatching(priceIdSelector),
currency_code: "usd",
amount: 100,
created_at: expect.any(String),
@@ -1510,7 +1520,7 @@ medusaIntegrationTestRunner({
variant_id: expect.stringMatching(/^variant_*/),
}),
expect.objectContaining({
id: expect.stringMatching(/^ma_*/),
id: expect.stringMatching(priceIdSelector),
currency_code: "eur",
amount: 45,
created_at: expect.any(String),
@@ -1518,7 +1528,7 @@ medusaIntegrationTestRunner({
variant_id: expect.stringMatching(/^variant_*/),
}),
expect.objectContaining({
id: expect.stringMatching(/^ma_*/),
id: expect.stringMatching(priceIdSelector),
currency_code: "dkk",
amount: 30,
created_at: expect.any(String),
@@ -1579,8 +1589,10 @@ medusaIntegrationTestRunner({
title: "Test",
discountable: false,
description: "test-product-description",
type: { value: "test-type" },
images: ["test-image.png", "test-image-2.png"],
images: breaking(
() => ["test-image.png", "test-image-2.png"],
() => [{ url: "test-image.png" }, { url: "test-image-2.png" }]
),
collection_id: "test-collection",
tags: [{ value: "123" }, { value: "456" }],
variants: [
@@ -1610,8 +1622,10 @@ medusaIntegrationTestRunner({
const payload = {
title: "Test product - 1",
description: "test-product-description 1",
type: { value: "test-type 1" },
images: ["test-image.png", "test-image-2.png"],
images: breaking(
() => ["test-image.png", "test-image-2.png"],
() => [{ url: "test-image.png" }, { url: "test-image-2.png" }]
),
collection_id: "test-collection",
tags: [{ value: "123" }, { value: "456" }],
variants: [
@@ -1706,8 +1720,10 @@ medusaIntegrationTestRunner({
},
],
tags: [{ value: "123" }],
images: ["test-image-2.png"],
type: { value: "test-type-2" },
images: breaking(
() => ["test-image-2.png"],
() => [{ url: "test-image-2.png" }]
),
status: "published",
}
@@ -1771,8 +1787,7 @@ medusaIntegrationTestRunner({
updated_at: expect.any(String),
value: "test-type-2",
}),
// TODO: For some reason this is `test-type`, but the ID is correct in the `type` property.
// type_id: expect.stringMatching(/^ptyp_*/),
type_id: expect.stringMatching(/^ptyp_*/),
updated_at: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
@@ -2929,8 +2944,10 @@ medusaIntegrationTestRunner({
title: "Test product",
handle: "test-product",
description: "test-product-description",
type: { value: "test-type" },
images: ["test-image.png", "test-image-2.png"],
images: breaking(
() => ["test-image.png", "test-image-2.png"],
() => [{ url: "test-image.png" }, { url: "test-image-2.png" }]
),
collection_id: "test-collection",
tags: [{ value: "123" }, { value: "456" }],
variants: [
@@ -2964,8 +2981,10 @@ medusaIntegrationTestRunner({
title: "Test product",
handle: "test-product",
description: "test-product-description",
type: { value: "test-type" },
images: ["test-image.png", "test-image-2.png"],
images: breaking(
() => ["test-image.png", "test-image-2.png"],
() => [{ url: "test-image.png" }, { url: "test-image-2.png" }]
),
collection_id: "test-collection",
tags: [{ value: "123" }, { value: "456" }],
variants: [

View File

@@ -7,6 +7,7 @@ import {
import { DataSource } from "typeorm"
import faker from "faker"
import { breaking } from "../helpers/breaking"
export type ProductVariantFactoryData = {
product_id: string
@@ -62,22 +63,24 @@ export const simpleProductVariantFactory = async (
})
}
const prices = data.prices || [{ currency: "usd", amount: 100 }]
for (const p of prices) {
const ma_id = `${p.currency}-${p.amount}-${Math.random()}`
await manager.insert(MoneyAmount, {
id: ma_id,
currency_code: p.currency,
amount: p.amount,
region_id: p.region_id,
})
await breaking(async () => {
const prices = data.prices || [{ currency: "usd", amount: 100 }]
for (const p of prices) {
const ma_id = `${p.currency}-${p.amount}-${Math.random()}`
await manager.insert(MoneyAmount, {
id: ma_id,
currency_code: p.currency,
amount: p.amount,
region_id: p.region_id,
})
await manager.insert(ProductVariantMoneyAmount, {
id: `${ma_id}-${id}-${Math.random()}`,
money_amount_id: ma_id,
variant_id: id,
})
}
await manager.insert(ProductVariantMoneyAmount, {
id: `${ma_id}-${id}-${Math.random()}`,
money_amount_id: ma_id,
variant_id: id,
})
}
})
return variant
}

View File

@@ -12,6 +12,7 @@ const {
MoneyAmount,
ProductVariantMoneyAmount,
} = require("@medusajs/medusa")
const { breaking } = require("./breaking")
module.exports = async (dataSource, data = {}) => {
const manager = dataSource.manager
@@ -141,17 +142,22 @@ module.exports = async (dataSource, data = {}) => {
await manager.save(variant1)
const ma = await manager.insert(MoneyAmount, {
id: "test-price",
currency_code: "usd",
amount: 100,
})
await breaking(
async () => {
const ma = await manager.insert(MoneyAmount, {
id: "test-price",
currency_code: "usd",
amount: 100,
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma0",
money_amount_id: "test-price",
variant_id: "test-variant",
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma0",
money_amount_id: "test-price",
variant_id: "test-variant",
})
},
() => {}
)
const sale = manager.create(ProductVariant, {
id: "test-variant-sale",
@@ -174,17 +180,22 @@ module.exports = async (dataSource, data = {}) => {
await manager.save(sale)
const ma_sale = await manager.insert(MoneyAmount, {
id: "test-price-sale",
currency_code: "usd",
amount: 1000,
})
await breaking(
async () => {
const ma_sale = await manager.insert(MoneyAmount, {
id: "test-price-sale",
currency_code: "usd",
amount: 1000,
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma1",
money_amount_id: "test-price-sale",
variant_id: "test-variant-sale",
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma1",
money_amount_id: "test-price-sale",
variant_id: "test-variant-sale",
})
},
() => {}
)
const variant2 = manager.create(ProductVariant, {
id: "test-variant_1",
@@ -207,17 +218,22 @@ module.exports = async (dataSource, data = {}) => {
await manager.save(variant2)
const ma_1 = await manager.insert(MoneyAmount, {
id: "test-price_1",
currency_code: "usd",
amount: 1000,
})
await breaking(
async () => {
const ma_1 = await manager.insert(MoneyAmount, {
id: "test-price_1",
currency_code: "usd",
amount: 1000,
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma2",
money_amount_id: "test-price_1",
variant_id: "test-variant_1",
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma2",
money_amount_id: "test-price_1",
variant_id: "test-variant_1",
})
},
() => {}
)
const variant3 = manager.create(ProductVariant, {
id: "test-variant_2",
@@ -239,17 +255,22 @@ module.exports = async (dataSource, data = {}) => {
await manager.save(variant3)
const ma_2 = await manager.insert(MoneyAmount, {
id: "test-price_2",
currency_code: "usd",
amount: 100,
})
await breaking(
async () => {
const ma_2 = await manager.insert(MoneyAmount, {
id: "test-price_2",
currency_code: "usd",
amount: 100,
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma3",
money_amount_id: "test-price_2",
variant_id: "test-variant_2",
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma3",
money_amount_id: "test-price_2",
variant_id: "test-variant_2",
})
},
() => {}
)
const p1 = manager.create(Product, {
id: "test-product1",
@@ -288,18 +309,23 @@ module.exports = async (dataSource, data = {}) => {
await manager.save(variant4)
const ma_3 = await manager.insert(MoneyAmount, {
id: "test-price_3",
currency_code: "usd",
amount: 100,
region_id: "test-region",
})
await breaking(
async () => {
const ma_3 = await manager.insert(MoneyAmount, {
id: "test-price_3",
currency_code: "usd",
amount: 100,
region_id: "test-region",
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma4",
money_amount_id: "test-price_3",
variant_id: "test-variant_3",
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma4",
money_amount_id: "test-price_3",
variant_id: "test-variant_3",
})
},
() => {}
)
const variant5 = manager.create(ProductVariant, {
id: "test-variant_4",
@@ -321,17 +347,22 @@ module.exports = async (dataSource, data = {}) => {
await manager.save(variant5)
const ma_4 = await manager.insert(MoneyAmount, {
id: "test-price_4",
currency_code: "usd",
amount: 100,
})
await breaking(
async () => {
const ma_4 = await manager.insert(MoneyAmount, {
id: "test-price_4",
currency_code: "usd",
amount: 100,
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma5",
money_amount_id: "test-price_4",
variant_id: "test-variant_4",
})
await manager.insert(ProductVariantMoneyAmount, {
id: "pvma5",
money_amount_id: "test-price_4",
variant_id: "test-variant_4",
})
},
() => {}
)
const product1 = manager.create(Product, {
id: "test-product_filtering_1",

View File

@@ -11,6 +11,10 @@ interface Input {
export function prepareLineItemData(data: Input) {
const { variant, unitPrice, quantity, metadata, cartId } = data
if (!variant.product) {
throw new Error("Variant does not have a product")
}
const lineItem: any = {
quantity,
title: variant.title,

View File

@@ -75,13 +75,13 @@ export async function updateProductVariantsPrepareData({
}
const variantsData: ProductWorkflow.UpdateProductVariantsInputDTO[] =
productVariantsMap.get(variantWithProductID.product_id) || []
productVariantsMap.get(variantWithProductID.product_id!) || []
if (variantData) {
variantsData.push(variantData)
}
productVariantsMap.set(variantWithProductID.product_id, variantsData)
productVariantsMap.set(variantWithProductID.product_id!, variantsData)
}
return {

View File

@@ -40,6 +40,13 @@ export const updateProductOptionsStep = createStep(
ModuleRegistrationName.PRODUCT
)
await service.upsertOptions(prevData)
await service.upsertOptions(
prevData.map((o) => ({
...o,
values: o.values?.map((v) => v.value),
product: undefined,
product_id: o.product_id ?? undefined,
}))
)
}
)

View File

@@ -36,7 +36,7 @@ export const createProductsWorkflow = createWorkflow(
const createdProducts = createProductsStep(productWithoutPrices)
// Note: We rely on the same order of input and output when creating products here, make sure that assumption holds
// Note: We rely on the same order of input and output when creating products here, ensure this always holds true
const variantsWithAssociatedPrices = transform(
{ input, createdProducts },
(data) => {

View File

@@ -1,4 +1,9 @@
import { MedusaContainer, ProductDTO, ProductVariantDTO } from "@medusajs/types"
import {
CreateProductDTO,
MedusaContainer,
ProductDTO,
ProductVariantDTO,
} from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
const isPricing = (fieldName: string) =>
@@ -45,8 +50,14 @@ export const remapVariant = (v: ProductVariantDTO) => {
return {
...v,
prices: (v as any).price_set?.prices?.map((price) => ({
...price,
id: price.id,
amount: price.amount,
currency_code: price.currency_code,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
variant_id: v.id,
created_at: price.created_at,
updated_at: price.updated_at,
})),
price_set: undefined,
}

View File

@@ -41,11 +41,7 @@ export const POST = async (
req: AuthenticatedMedusaRequest<CreateProductDTO>,
res: MedusaResponse
) => {
const input = [
{
...req.validatedBody,
},
]
const input = [req.validatedBody]
const { result, errors } = await createProductsWorkflow(req.scope).run({
input: { products: input },

View File

@@ -287,9 +287,8 @@ export class AdminPostProductsReq {
status?: ProductStatus = ProductStatus.DRAFT
@IsOptional()
@Type(() => ProductTypeReq)
@ValidateNested()
type?: ProductTypeReq
@IsString()
type_id?: string
@IsOptional()
@IsString()
@@ -400,9 +399,8 @@ export class AdminPostProductsProductReq {
status?: ProductStatus
@IsOptional()
@Type(() => ProductTypeReq)
@ValidateNested()
type?: ProductTypeReq
@IsString()
type_id?: string
@IsOptional()
@IsString()

View File

@@ -42,7 +42,7 @@ export const buildProductAndRelationsData = ({
thumbnail,
images,
status,
type,
type_id,
tags,
options,
variants,
@@ -60,7 +60,7 @@ export const buildProductAndRelationsData = ({
thumbnail: thumbnail as string,
status: status ?? ProductTypes.ProductStatus.PUBLISHED,
images: (images ?? []) as Image[],
type: type ? { value: type } : { value: faker.commerce.productName() },
type_id,
tags: tags ?? [{ value: "tag-1" }],
collection_id,
options: options ?? [

View File

@@ -180,6 +180,7 @@ moduleIntegrationTestRunner({
{
id: "test-2",
title: "col 2",
handle: "col-2",
products: [],
},
])
@@ -253,6 +254,7 @@ moduleIntegrationTestRunner({
expect(serialized).toEqual({
id: collectionData.id,
title: collectionData.title,
handle: "collection-1",
})
})
@@ -272,6 +274,7 @@ moduleIntegrationTestRunner({
expect(serialized).toEqual({
id: collectionData.id,
title: collectionData.title,
handle: "collection-1",
products: [],
})
})

View File

@@ -354,18 +354,15 @@ moduleIntegrationTestRunner({
let error
try {
await service.upsertCollections([
{
id: "does-not-exist",
title: "New Collection",
},
])
await service.updateCollections("does-not-exist", {
title: "New Collection",
})
} catch (e) {
error = e
}
expect(error.message).toEqual(
'ProductCollection with id "does-not-exist" not found'
"ProductCollection with id: does-not-exist was not found"
)
})
})

View File

@@ -103,6 +103,8 @@ moduleIntegrationTestRunner({
product_id: productOne.id,
product: {
id: productOne.id,
type_id: null,
collection_id: null,
},
},
])
@@ -175,6 +177,8 @@ moduleIntegrationTestRunner({
product_id: productOne.id,
product: {
id: productOne.id,
type_id: null,
collection_id: null,
},
},
])
@@ -203,8 +207,12 @@ moduleIntegrationTestRunner({
id: optionOne.id,
product: {
id: "product-1",
handle: "product-1",
title: "product 1",
type_id: null,
collection_id: null,
},
product_id: "product-1",
})
)
})
@@ -258,17 +266,13 @@ moduleIntegrationTestRunner({
let error
try {
await service.upsertOptions([
{
id: "does-not-exist",
},
])
await service.updateOptions("does-not-exist", {})
} catch (e) {
error = e
}
expect(error.message).toEqual(
`Option with id "does-not-exist" does not exist, but was referenced in the update request`
`ProductOption with id: does-not-exist was not found`
)
})
})

View File

@@ -102,6 +102,8 @@ moduleIntegrationTestRunner({
value: tagOne.value,
products: [
{
collection_id: null,
type_id: null,
id: productOne.id,
},
],
@@ -175,6 +177,8 @@ moduleIntegrationTestRunner({
value: tagOne.value,
products: [
{
collection_id: null,
type_id: null,
id: productOne.id,
},
],

View File

@@ -9,14 +9,17 @@ import {
ProductVariant,
} from "@models"
import { MockEventBusService } from "medusa-test-utils"
import {
MockEventBusService,
moduleIntegrationTestRunner,
SuiteOptions,
} from "medusa-test-utils"
import { createCollections, createTypes } from "../../../__fixtures__/product"
import { createProductCategories } from "../../../__fixtures__/product-category"
import { buildProductAndRelationsData } from "../../../__fixtures__/product/data/create-product"
import { UpdateProductInput } from "../../../../src/types/services/product"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { UpdateProductInput } from "@types"
jest.setTimeout(30000)
jest.setTimeout(300000)
moduleIntegrationTestRunner({
moduleName: Modules.PRODUCT,
@@ -54,7 +57,7 @@ moduleIntegrationTestRunner({
let variantTwo: ProductVariant
let productTypeOne: ProductType
let productTypeTwo: ProductType
let images = ["image-1"]
let images = [{ url: "image-1" }]
const productCategoriesData = [
{
@@ -151,7 +154,7 @@ moduleIntegrationTestRunner({
it("should update a product and upsert relations that are not created yet", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
thumbnail: images[0].url,
})
const variantTitle = data.variants[0].title
@@ -173,12 +176,10 @@ moduleIntegrationTestRunner({
...productBefore.variants!,
...data.variants,
]
productBefore.type = { value: "new-type" }
productBefore.options = data.options
productBefore.images = data.images
productBefore.thumbnail = data.thumbnail
productBefore.tags = data.tags
const updatedProducts = await service.upsert([productBefore])
expect(updatedProducts).toHaveLength(1)
@@ -211,12 +212,12 @@ moduleIntegrationTestRunner({
subtitle: productBefore.subtitle,
is_giftcard: productBefore.is_giftcard,
discountable: productBefore.discountable,
thumbnail: images[0],
thumbnail: images[0].url,
status: productBefore.status,
images: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
url: images[0],
url: images[0].url,
}),
]),
options: expect.arrayContaining([
@@ -237,10 +238,6 @@ moduleIntegrationTestRunner({
value: productBefore.tags?.[0].value,
}),
]),
type: expect.objectContaining({
id: expect.any(String),
value: productBefore.type!.value,
}),
variants: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
@@ -268,7 +265,7 @@ moduleIntegrationTestRunner({
const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
thumbnail: images[0].url,
})
const updateData = {
@@ -328,10 +325,7 @@ moduleIntegrationTestRunner({
it("should upsert a product type when type object is passed", async () => {
let updateData = {
id: productTwo.id,
type: {
id: productTypeOne.id,
value: productTypeOne.value,
},
type_id: productTypeOne.id,
}
await service.upsert([updateData])
@@ -348,29 +342,6 @@ moduleIntegrationTestRunner({
}),
})
)
updateData = {
id: productTwo.id,
type: {
id: "new-type-id",
value: "new-type-value",
},
}
await service.upsert([updateData])
product = await service.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 () => {
@@ -421,8 +392,7 @@ moduleIntegrationTestRunner({
)
})
// TODO: Currently the base repository doesn't remove relationships if an empty array is passed, we need to fix that in the base repo.
it.skip("should remove relationships of a product", async () => {
it("should remove relationships of a product", async () => {
const updateData = {
id: productTwo.id,
categories: [],
@@ -450,18 +420,13 @@ moduleIntegrationTestRunner({
it("should throw an error when product ID does not exist", async () => {
let error
const updateData = {
id: "does-not-exist",
title: "test",
}
try {
await service.upsert([updateData])
await service.update("does-not-exist", { title: "test" })
} catch (e) {
error = e.message
}
expect(error).toEqual(`Product with id "does-not-exist" not found`)
expect(error).toEqual(`Product with id: does-not-exist was not found`)
})
it("should update, create and delete variants", async () => {
@@ -503,15 +468,13 @@ moduleIntegrationTestRunner({
)
})
it("should throw an error when variant with id does not exist", async () => {
let error
it("should createa variant with id that was passed if it does not exist", async () => {
const updateData = {
id: productTwo.id,
// Note: VariantThree is already assigned to productTwo, that should be deleted
variants: [
{
id: "does-not-exist",
id: "passed-id",
title: "updated-variant",
},
{
@@ -520,28 +483,33 @@ moduleIntegrationTestRunner({
],
}
try {
await service.upsert([updateData])
} catch (e) {
error = e
}
await service.retrieve(updateData.id, {
await service.upsert([updateData])
const retrieved = await service.retrieve(updateData.id, {
relations: ["variants"],
})
expect(error.message).toEqual(
`Variant with id "does-not-exist" does not exist, but was referenced in the update request`
expect(retrieved.variants).toHaveLength(2)
expect(retrieved.variants).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "passed-id",
title: "updated-variant",
}),
expect.objectContaining({
id: expect.any(String),
title: "created-variant",
}),
])
)
})
})
describe("create", function () {
let images = ["image-1"]
let images = [{ url: "image-1" }]
it("should create a product", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
thumbnail: images[0].url,
})
const productsCreated = await service.create([data])
@@ -557,7 +525,6 @@ moduleIntegrationTestRunner({
"options",
"options.values",
"tags",
"type",
],
}
)
@@ -578,12 +545,12 @@ moduleIntegrationTestRunner({
subtitle: data.subtitle,
is_giftcard: data.is_giftcard,
discountable: data.discountable,
thumbnail: images[0],
thumbnail: images[0].url,
status: data.status,
images: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
url: images[0],
url: images[0].url,
}),
]),
options: expect.arrayContaining([
@@ -604,10 +571,6 @@ moduleIntegrationTestRunner({
value: data.tags[0].value,
}),
]),
type: expect.objectContaining({
id: expect.any(String),
value: data.type.value,
}),
variants: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
@@ -635,7 +598,7 @@ moduleIntegrationTestRunner({
const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
thumbnail: images[0].url,
})
const products = await service.create([data])
@@ -650,11 +613,11 @@ moduleIntegrationTestRunner({
})
describe("softDelete", function () {
let images = ["image-1"]
let images = [{ url: "image-1" }]
it("should soft delete a product and its cascaded relations", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
thumbnail: images[0].url,
})
const products = await service.create([data])
@@ -705,7 +668,7 @@ moduleIntegrationTestRunner({
it("should retrieve soft-deleted products if filtered on deleted_at", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
thumbnail: images[0].url,
})
const products = await service.create([data])
@@ -723,7 +686,7 @@ moduleIntegrationTestRunner({
const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
thumbnail: images[0].url,
})
const products = await service.create([data])
@@ -740,12 +703,12 @@ moduleIntegrationTestRunner({
})
describe("restore", function () {
let images = ["image-1"]
let images = [{ url: "image-1" }]
it("should restore a soft deleted product and its cascaded relations", async () => {
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
thumbnail: images[0].url,
})
const products = await service.create([data])
@@ -852,7 +815,7 @@ moduleIntegrationTestRunner({
])
})
it("should returns empty array when querying for a collection that doesnt exist", async () => {
it("should return empty array when querying for a collection that doesnt exist", async () => {
const products = await service.list(
{
categories: { id: ["collection-doesnt-exist-id"] },

View File

@@ -210,6 +210,7 @@ moduleIntegrationTestRunner({
expect(serialized).toEqual({
id: optionId,
title: optionValue,
product_id: null,
})
})
})

View File

@@ -184,8 +184,11 @@ moduleIntegrationTestRunner({
products: [
{
id: "test-1",
collection_id: null,
type_id: null,
},
],
value: "France",
}),
])
})

View File

@@ -379,18 +379,21 @@ moduleIntegrationTestRunner({
name: "category 0",
handle: "category-0",
mpath: "category-0.",
parent_category_id: null,
},
{
id: "category-1",
name: "category 1",
handle: "category-1",
mpath: "category-0.category-1.",
parent_category_id: null,
},
{
id: "category-1-a",
name: "category 1 a",
handle: "category-1-a",
mpath: "category-0.category-1.category-1-a.",
parent_category_id: null,
},
])
})
@@ -482,7 +485,11 @@ moduleIntegrationTestRunner({
{
id: workingProduct.id,
title: workingProduct.title,
handle: "product-1",
collection_id: workingCollection.id,
type_id: null,
collection: {
handle: "col-1",
id: workingCollection.id,
title: workingCollection.title,
},
@@ -508,8 +515,11 @@ moduleIntegrationTestRunner({
{
id: workingProduct.id,
title: workingProduct.title,
handle: "product-1",
type_id: null,
collection_id: workingCollection.id,
collection: {
handle: "col-1",
id: workingCollection.id,
title: workingCollection.title,
},
@@ -517,8 +527,11 @@ moduleIntegrationTestRunner({
{
id: workingProductTwo.id,
title: workingProductTwo.title,
handle: "product",
type_id: null,
collection_id: workingCollectionTwo.id,
collection: {
handle: "col-2",
id: workingCollectionTwo.id,
title: workingCollectionTwo.title,
},

View File

@@ -944,7 +944,7 @@
"id"
],
"referencedTableName": "public.product",
"deleteRule": "set null",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
@@ -1063,6 +1063,7 @@
"id"
],
"referencedTableName": "public.product_option",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}

View File

@@ -1,179 +0,0 @@
import { Migration } from "@mikro-orm/migrations"
export class InitialSetup20240315083440 extends Migration {
async up(): Promise<void> {
// TODO: These migrations that get generated don't even reflect the models, write by hand.
const productTables = await this.execute(
"select * from information_schema.tables where table_name = 'product' and table_schema = 'public'"
)
if (productTables.length > 0) {
// This is so we can still run the api tests, remove completely once that is not needed
this.addSql(
`alter table "product_option_value" alter column "variant_id" drop not null;`
)
}
this.addSql(
'create table if not exists "product_category" ("id" text not null, "name" text not null, "description" text not null default \'\', "handle" text not null, "mpath" text not null, "is_active" boolean not null default false, "is_internal" boolean not null default false, "rank" numeric not null default 0, "parent_category_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), constraint "product_category_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_category_path" on "product_category" ("mpath");'
)
this.addSql(
'create table if not exists "product_collection" ("id" text not null, "title" text not null, "handle" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_collection_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_collection_deleted_at" on "product_collection" ("deleted_at");'
)
this.addSql(
'alter table if exists "product_collection" add constraint "IDX_product_collection_handle_unique" unique ("handle");'
)
this.addSql(
'create table if not exists "image" ("id" text not null, "url" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "image_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_image_url" on "image" ("url");'
)
this.addSql(
'create index if not exists "IDX_product_image_deleted_at" on "image" ("deleted_at");'
)
this.addSql(
'create table if not exists "product_tag" ("id" text not null, "value" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_tag_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_tag_deleted_at" on "product_tag" ("deleted_at");'
)
this.addSql(
'create table if not exists "product_type" ("id" text not null, "value" text not null, "metadata" json null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_type_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_type_deleted_at" on "product_type" ("deleted_at");'
)
this.addSql(
'create table if not exists "product" ("id" text not null, "title" text not null, "handle" text not null, "subtitle" text null, "description" text null, "is_giftcard" boolean not null default false, "status" text check ("status" in (\'draft\', \'proposed\', \'published\', \'rejected\')) not null, "thumbnail" text null, "weight" text null, "length" text null, "height" text null, "width" text null, "origin_country" text null, "hs_code" text null, "mid_code" text null, "material" text null, "collection_id" text null, "type_id" text null, "discountable" boolean not null default true, "external_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "metadata" jsonb null, constraint "product_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_type_id" on "product" ("type_id");'
)
this.addSql(
'create index if not exists "IDX_product_deleted_at" on "product" ("deleted_at");'
)
this.addSql(
'alter table if exists "product" add constraint "IDX_product_handle_unique" unique ("handle");'
)
this.addSql(
'create table if not exists "product_option" ("id" text not null, "title" text not null, "product_id" text null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_option_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_option_deleted_at" on "product_option" ("deleted_at");'
)
this.addSql(
'create table if not exists "product_option_value" ("id" text not null, "value" text not null, "option_id" text null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_option_value_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_option_value_option_id" on "product_option_value" ("option_id");'
)
this.addSql(
'create index if not exists "IDX_product_option_value_deleted_at" on "product_option_value" ("deleted_at");'
)
this.addSql(
'create table if not exists "product_tags" ("product_id" text not null, "product_tag_id" text not null, constraint "product_tags_pkey" primary key ("product_id", "product_tag_id"));'
)
this.addSql(
'create table if not exists "product_images" ("product_id" text not null, "image_id" text not null, constraint "product_images_pkey" primary key ("product_id", "image_id"));'
)
this.addSql(
'create table if not exists "product_category_product" ("product_id" text not null, "product_category_id" text not null, constraint "product_category_product_pkey" primary key ("product_id", "product_category_id"));'
)
this.addSql(
'create table if not exists "product_variant" ("id" text not null, "title" text not null, "sku" text null, "barcode" text null, "ean" text null, "upc" text null, "inventory_quantity" numeric not null default 100, "allow_backorder" boolean not null default false, "manage_inventory" boolean not null default true, "hs_code" text null, "origin_country" text null, "mid_code" text null, "material" text null, "weight" numeric null, "length" numeric null, "height" numeric null, "width" numeric null, "metadata" jsonb null, "variant_rank" numeric null default 0, "product_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_variant_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_variant_product_id" on "product_variant" ("product_id");'
)
this.addSql(
'create index if not exists "IDX_product_variant_deleted_at" on "product_variant" ("deleted_at");'
)
this.addSql(
'alter table if exists "product_variant" add constraint "IDX_product_variant_sku_unique" unique ("sku");'
)
this.addSql(
'alter table if exists "product_variant" add constraint "IDX_product_variant_barcode_unique" unique ("barcode");'
)
this.addSql(
'alter table if exists "product_variant" add constraint "IDX_product_variant_ean_unique" unique ("ean");'
)
this.addSql(
'alter table if exists "product_variant" add constraint "IDX_product_variant_upc_unique" unique ("upc");'
)
this.addSql(
'create table if not exists "product_variant_option" ("id" text not null, "option_value_id" text null, "variant_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_variant_option_pkey" primary key ("id"));'
)
this.addSql(
'create index if not exists "IDX_product_variant_option_deleted_at" on "product_variant_option" ("deleted_at");'
)
this.addSql(
'alter table if exists "product_category" add constraint "product_category_parent_category_id_foreign" foreign key ("parent_category_id") references "product_category" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table if exists "product" add constraint "product_collection_id_foreign" foreign key ("collection_id") references "product_collection" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table if exists "product" add constraint "product_type_id_foreign" foreign key ("type_id") references "product_type" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table if exists "product_option" add constraint "product_option_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table if exists "product_option_value" add constraint "product_option_value_option_id_foreign" foreign key ("option_id") references "product_option" ("id") on update cascade;'
)
this.addSql(
'alter table if exists "product_tags" add constraint "product_tags_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "product_tags" add constraint "product_tags_product_tag_id_foreign" foreign key ("product_tag_id") references "product_tag" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "product_images" add constraint "product_images_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "product_images" add constraint "product_images_image_id_foreign" foreign key ("image_id") references "image" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "product_category_product" add constraint "product_category_product_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "product_category_product" add constraint "product_category_product_product_category_id_foreign" foreign key ("product_category_id") references "product_category" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "product_variant" add constraint "product_variant_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "product_variant_option" add constraint "product_variant_option_option_value_id_foreign" foreign key ("option_value_id") references "product_option_value" ("id") on update cascade on delete set null;'
)
this.addSql(
'alter table if exists "product_variant_option" add constraint "product_variant_option_variant_id_foreign" foreign key ("variant_id") references "product_variant" ("id") on update cascade on delete set null;'
)
}
}

View File

@@ -0,0 +1,92 @@
import { Migration } from '@mikro-orm/migrations';
export class InitialSetup20240315083440 extends Migration {
async up(): Promise<void> {
// TODO: These migrations that get generated don't even reflect the models, write by hand.
const productTables = await this.execute(
"select * from information_schema.tables where table_name = 'product' and table_schema = 'public'"
)
if (productTables.length > 0) {
// This is so we can still run the api tests, remove completely once that is not needed
this.addSql(
`alter table "product_option_value" alter column "variant_id" drop not null;`
)
this.addSql(
`alter table "product_variant" alter column "inventory_quantity" drop not null;`
)
}
this.addSql('create table if not exists "product_category" ("id" text not null, "name" text not null, "description" text not null default \'\', "handle" text not null, "mpath" text not null, "is_active" boolean not null default false, "is_internal" boolean not null default false, "rank" numeric not null default 0, "parent_category_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), constraint "product_category_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_category_path" on "product_category" ("mpath");');
// TODO: Re-enable when we run the migration from v1
// this.addSql('alter table if exists "product_category" add constraint "IDX_product_category_handle" unique ("handle");');
this.addSql('create table if not exists "product_collection" ("id" text not null, "title" text not null, "handle" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_collection_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_collection_deleted_at" on "product_collection" ("deleted_at");');
this.addSql('alter table if exists "product_collection" add constraint "IDX_product_collection_handle_unique" unique ("handle");');
this.addSql('create table if not exists "image" ("id" text not null, "url" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "image_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_image_url" on "image" ("url");');
this.addSql('create index if not exists "IDX_product_image_deleted_at" on "image" ("deleted_at");');
this.addSql('create table if not exists "product_tag" ("id" text not null, "value" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_tag_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_tag_deleted_at" on "product_tag" ("deleted_at");');
this.addSql('create table if not exists "product_type" ("id" text not null, "value" text not null, "metadata" json null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_type_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_type_deleted_at" on "product_type" ("deleted_at");');
this.addSql('create table if not exists "product" ("id" text not null, "title" text not null, "handle" text not null, "subtitle" text null, "description" text null, "is_giftcard" boolean not null default false, "status" text check ("status" in (\'draft\', \'proposed\', \'published\', \'rejected\')) not null, "thumbnail" text null, "weight" text null, "length" text null, "height" text null, "width" text null, "origin_country" text null, "hs_code" text null, "mid_code" text null, "material" text null, "collection_id" text null, "type_id" text null, "discountable" boolean not null default true, "external_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "metadata" jsonb null, constraint "product_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_type_id" on "product" ("type_id");');
this.addSql('create index if not exists "IDX_product_deleted_at" on "product" ("deleted_at");');
this.addSql('alter table if exists "product" add constraint "IDX_product_handle_unique" unique ("handle");');
this.addSql('create table if not exists "product_option" ("id" text not null, "title" text not null, "product_id" text null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_option_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_option_deleted_at" on "product_option" ("deleted_at");');
this.addSql('create table if not exists "product_option_value" ("id" text not null, "value" text not null, "option_id" text null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_option_value_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_option_value_option_id" on "product_option_value" ("option_id");');
this.addSql('create index if not exists "IDX_product_option_value_deleted_at" on "product_option_value" ("deleted_at");');
this.addSql('create table if not exists "product_tags" ("product_id" text not null, "product_tag_id" text not null, constraint "product_tags_pkey" primary key ("product_id", "product_tag_id"));');
this.addSql('create table if not exists "product_images" ("product_id" text not null, "image_id" text not null, constraint "product_images_pkey" primary key ("product_id", "image_id"));');
this.addSql('create table if not exists "product_category_product" ("product_id" text not null, "product_category_id" text not null, constraint "product_category_product_pkey" primary key ("product_id", "product_category_id"));');
this.addSql('create table if not exists "product_variant" ("id" text not null, "title" text not null, "sku" text null, "barcode" text null, "ean" text null, "upc" text null, "inventory_quantity" numeric not null default 100, "allow_backorder" boolean not null default false, "manage_inventory" boolean not null default true, "hs_code" text null, "origin_country" text null, "mid_code" text null, "material" text null, "weight" numeric null, "length" numeric null, "height" numeric null, "width" numeric null, "metadata" jsonb null, "variant_rank" numeric null default 0, "product_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_variant_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_variant_product_id" on "product_variant" ("product_id");');
this.addSql('create index if not exists "IDX_product_variant_deleted_at" on "product_variant" ("deleted_at");');
this.addSql('alter table if exists "product_variant" add constraint "IDX_product_variant_sku_unique" unique ("sku");');
this.addSql('alter table if exists "product_variant" add constraint "IDX_product_variant_barcode_unique" unique ("barcode");');
this.addSql('alter table if exists "product_variant" add constraint "IDX_product_variant_ean_unique" unique ("ean");');
this.addSql('alter table if exists "product_variant" add constraint "IDX_product_variant_upc_unique" unique ("upc");');
this.addSql('create table if not exists "product_variant_option" ("id" text not null, "option_value_id" text null, "variant_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_variant_option_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_variant_option_deleted_at" on "product_variant_option" ("deleted_at");');
this.addSql('alter table if exists "product_category" add constraint "product_category_parent_category_id_foreign" foreign key ("parent_category_id") references "product_category" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "product" add constraint "product_collection_id_foreign" foreign key ("collection_id") references "product_collection" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "product" add constraint "product_type_id_foreign" foreign key ("type_id") references "product_type" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "product_option" add constraint "product_option_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_option_value" add constraint "product_option_value_option_id_foreign" foreign key ("option_id") references "product_option" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_tags" add constraint "product_tags_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_tags" add constraint "product_tags_product_tag_id_foreign" foreign key ("product_tag_id") references "product_tag" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_images" add constraint "product_images_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_images" add constraint "product_images_image_id_foreign" foreign key ("image_id") references "image" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_category_product" add constraint "product_category_product_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_category_product" add constraint "product_category_product_product_category_id_foreign" foreign key ("product_category_id") references "product_category" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_variant" add constraint "product_variant_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_variant_option" add constraint "product_variant_option_option_value_id_foreign" foreign key ("option_value_id") references "product_option_value" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "product_variant_option" add constraint "product_variant_option_variant_id_foreign" foreign key ("variant_id") references "product_variant" ("id") on update cascade on delete set null;');
}
}

View File

@@ -56,10 +56,15 @@ class ProductCategory {
@Property({ columnType: "numeric", nullable: false, default: 0 })
rank?: number
@Property({ columnType: "text", nullable: true })
@ManyToOne(() => ProductCategory, {
columnType: "text",
fieldName: "parent_category_id",
nullable: true,
mapToPk: true,
})
parent_category_id?: string | null
@ManyToOne(() => ProductCategory, { nullable: true })
@ManyToOne(() => ProductCategory, { nullable: true, persist: false })
parent_category?: ProductCategory
@OneToMany({
@@ -89,11 +94,13 @@ class ProductCategory {
@OnInit()
async onInit() {
this.id = generateEntityId(this.id, "pcat")
this.parent_category_id ??= this.parent_category?.id ?? null
}
@BeforeCreate()
async onCreate(args: EventArgs<ProductCategory>) {
this.id = generateEntityId(this.id, "pcat")
this.parent_category_id ??= this.parent_category?.id ?? null
if (!this.handle && this.name) {
this.handle = kebabCase(this.name)

View File

@@ -65,13 +65,17 @@ class ProductCollection {
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "pcol")
if (!this.handle && this.title) {
this.handle = kebabCase(this.title)
}
}
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "pcol")
if (!this.handle) {
if (!this.handle && this.title) {
this.handle = kebabCase(this.title)
}
}

View File

@@ -47,14 +47,21 @@ class ProductOptionValue {
@Property({ columnType: "text" })
value: string
@Property({ columnType: "text", nullable: true })
option_id!: string
@ManyToOne(() => ProductOption, {
columnType: "text",
fieldName: "option_id",
mapToPk: true,
nullable: true,
index: "IDX_product_option_value_option_id",
onDelete: "cascade",
})
option_id: string | null
@ManyToOne(() => ProductOption, {
index: "IDX_product_option_value_option_id",
fieldName: "option_id",
nullable: true,
persist: false,
})
option: ProductOption
option: ProductOption | null
@OneToMany(() => ProductVariantOption, (value) => value.option_value, {})
variant_options = new Collection<ProductVariantOption>(this)
@@ -84,11 +91,13 @@ class ProductOptionValue {
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "optval")
this.option_id ??= this.option?.id ?? null
}
@BeforeCreate()
beforeCreate() {
this.id = generateEntityId(this.id, "optval")
this.option_id ??= this.option?.id ?? null
}
}

View File

@@ -48,14 +48,20 @@ class ProductOption {
@Property({ columnType: "text" })
title: string
@Property({ columnType: "text", nullable: true })
product_id!: string
@ManyToOne(() => Product, {
columnType: "text",
fieldName: "product_id",
mapToPk: true,
nullable: true,
onDelete: "cascade",
})
product_id: string | null
@ManyToOne(() => Product, {
fieldName: "product_id",
persist: false,
nullable: true,
})
product!: Product
product: Product | null
@OneToMany(() => ProductOptionValue, (value) => value.option, {
cascade: [Cascade.PERSIST, Cascade.REMOVE, "soft-remove" as any],
@@ -87,11 +93,13 @@ class ProductOption {
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "opt")
this.product_id ??= this.product?.id ?? null
}
@BeforeCreate()
beforeCreate() {
this.id = generateEntityId(this.id, "opt")
this.product_id ??= this.product?.id ?? null
}
}

View File

@@ -31,20 +31,30 @@ class ProductVariantOption {
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text", nullable: true })
@ManyToOne(() => ProductOptionValue, {
columnType: "text",
nullable: true,
fieldName: "option_value_id",
mapToPk: true,
})
option_value_id!: string
@ManyToOne(() => ProductOptionValue, {
fieldName: "option_value_id",
persist: false,
nullable: true,
})
option_value!: ProductOptionValue
@Property({ columnType: "text", nullable: true })
variant_id!: string
@ManyToOne(() => ProductVariant, {
columnType: "text",
nullable: true,
fieldName: "variant_id",
mapToPk: true,
})
variant_id: string | null
@ManyToOne(() => ProductVariant, {
fieldName: "variant_id",
persist: false,
nullable: true,
})
variant!: ProductVariant
@@ -71,11 +81,15 @@ class ProductVariantOption {
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "varopt")
this.variant_id ??= this.variant?.id ?? null
this.option_value_id ??= this.option_value?.id ?? null
}
@BeforeCreate()
beforeCreate() {
this.id = generateEntityId(this.id, "varopt")
this.variant_id ??= this.variant?.id ?? null
this.option_value_id ??= this.option_value?.id ?? null
}
}

View File

@@ -119,16 +119,21 @@ class ProductVariant {
})
variant_rank?: number | null
@Property({ columnType: "text", nullable: true })
product_id!: string
@ManyToOne(() => Product, {
columnType: "text",
nullable: true,
onDelete: "cascade",
fieldName: "product_id",
index: "IDX_product_variant_product_id",
mapToPk: true,
})
product_id: string | null
@ManyToOne(() => Product, {
onDelete: "cascade",
index: "IDX_product_variant_product_id",
fieldName: "product_id",
persist: false,
nullable: true,
})
product!: Product
product: Product | null
@Property({
onCreate: () => new Date(),
@@ -161,11 +166,13 @@ class ProductVariant {
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "variant")
this.product_id ??= this.product?.id ?? null
}
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "variant")
this.product_id ??= this.product?.id ?? null
}
}

View File

@@ -106,31 +106,39 @@ class Product {
@Property({ columnType: "text", nullable: true })
material?: string | null
@Property({ columnType: "text", nullable: true })
collection_id!: string
@ManyToOne(() => ProductCollection, {
columnType: "text",
nullable: true,
fieldName: "collection_id",
mapToPk: true,
})
collection_id: string | null
@ManyToOne(() => ProductCollection, {
nullable: true,
fieldName: "collection_id",
persist: false,
})
collection!: ProductCollection | null
collection: ProductCollection | null
@Property({ columnType: "text", nullable: true })
type_id!: string
@ManyToOne(() => ProductType, {
columnType: "text",
nullable: true,
fieldName: "type_id",
index: "IDX_product_type_id",
mapToPk: true,
})
type_id: string | null
@ManyToOne(() => ProductType, {
nullable: true,
index: "IDX_product_type_id",
fieldName: "type_id",
persist: false,
})
type!: ProductType
type: ProductType | null
@ManyToMany(() => ProductTag, "products", {
owner: true,
pivotTable: "product_tags",
index: "IDX_product_tag_id",
cascade: ["soft-remove"] as any,
// TODO: Do we really want to remove tags if the product is deleted?
})
tags = new Collection<ProductTag>(this)
@@ -138,7 +146,6 @@ class Product {
owner: true,
pivotTable: "product_images",
index: "IDX_product_image_id",
cascade: ["soft-remove"] as any,
joinColumn: "product_id",
inverseJoinColumn: "image_id",
})
@@ -147,7 +154,6 @@ class Product {
@ManyToMany(() => ProductCategory, "products", {
owner: true,
pivotTable: "product_category_product",
// TODO: rm cascade: ["soft-remove"] as any,
})
categories = new Collection<ProductCategory>(this)
@@ -182,12 +188,21 @@ class Product {
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "prod")
this.type_id ??= this.type?.id ?? null
this.collection_id ??= this.collection?.id ?? null
if (!this.handle && this.title) {
this.handle = kebabCase(this.title)
}
}
@BeforeCreate()
beforeCreate() {
this.id = generateEntityId(this.id, "prod")
if (!this.handle) {
this.type_id ??= this.type?.id ?? null
this.collection_id ??= this.collection?.id ?? null
if (!this.handle && this.title) {
this.handle = kebabCase(this.title)
}
}

View File

@@ -37,22 +37,22 @@ import {
MedusaContext,
MedusaError,
ModulesSdkUtils,
ProductStatus,
promiseAll,
} from "@medusajs/utils"
import {
ProductCategoryEventData,
ProductCategoryEvents,
UpdateCollectionInput,
ProductEventData,
ProductEvents,
UpdateProductInput,
ProductCollectionEventData,
ProductCollectionEvents,
UpdateProductVariantInput,
ProductEventData,
ProductEvents,
UpdateCollectionInput,
UpdateProductInput,
UpdateProductOptionInput,
UpdateProductVariantInput,
} from "../types"
import { entityNameToLinkableKeysMap, joinerConfig } from "./../joiner-config"
import { ProductStatus } from "@medusajs/utils"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
@@ -222,7 +222,7 @@ export default class ProductModuleService<
const createdVariants = await this.baseRepository_.serialize<
ProductTypes.ProductVariantDTO[]
>(variants, { populate: true })
>(variants)
return Array.isArray(data) ? createdVariants : createdVariants[0]
}
@@ -353,7 +353,7 @@ export default class ProductModuleService<
): Promise<TProductVariant[]> {
// Validation step
const variantIdsToUpdate = data.map(({ id }) => id)
const variants = await this.listVariants(
const variants = await this.productVariantService_.list(
{ id: variantIdsToUpdate },
{ relations: ["options"], take: null },
sharedContext
@@ -373,7 +373,7 @@ export default class ProductModuleService<
(v) => ({
...data.find((d) => d.id === v.id),
id: v.id,
product_id: v.product_id!,
product_id: v.product_id,
})
)
@@ -387,90 +387,18 @@ export default class ProductModuleService<
sharedContext
)
return await this.diffVariants_(
variantsWithProductId,
productOptions,
return this.productVariantService_.upsertWithReplace(
ProductModuleService.assignOptionsToVariants(
variantsWithProductId,
productOptions
),
{
relations: ["options"],
},
sharedContext
)
}
@InjectTransactionManager("baseRepository_")
protected async diffVariants_(
data: UpdateProductVariantInput[],
productOptions: ProductOption[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProductVariant[]> {
const toCreate = data.filter((o) => !o.id)
const toUpdate = data.filter((o) => o.id)
let createdVariants: TProductVariant[] = []
let updatedVariants: TProductVariant[] = []
if (toCreate.length) {
createdVariants = await this.productVariantService_.create(
ProductModuleService.assignOptionsToVariants(toCreate, productOptions),
sharedContext
)
}
if (toUpdate.length) {
const existingVariants = await this.productVariantService_.list(
{ id: toUpdate.map((o) => o.id) },
{ take: null },
sharedContext
)
const updateVariants = await promiseAll(
toUpdate.map(async (variantToUpdate) => {
const dbVariant = existingVariants.find(
(o) => o.id === variantToUpdate.id
)
if (!dbVariant) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variant with id "${variantToUpdate.id}" does not exist, but was referenced in the update request`
)
}
if (!variantToUpdate.options) {
return variantToUpdate
}
const dbVariantOptions = await this.productVariantOptionService_.list(
{ variant_id: dbVariant.id },
{ relations: ["option_value", "option_value.option"], take: null },
sharedContext
)
const variantOptionsToDelete = dbVariantOptions
.filter((variantOption) => {
return !Object.entries(variantToUpdate.options ?? {}).some(
([optionTitle, optionValue]) =>
variantOption.option_value.value === optionValue &&
variantOption.option_value.option.title === optionTitle
)
})
.map((v) => v.id)
await this.productVariantOptionService_.delete({
id: { $in: variantOptionsToDelete },
})
return variantToUpdate
})
)
updatedVariants = await this.productVariantService_.update(
ProductModuleService.assignOptionsToVariants(
updateVariants,
productOptions
),
sharedContext
)
}
return [...createdVariants, ...updatedVariants]
}
@InjectTransactionManager("baseRepository_")
async createTags(
data: ProductTypes.CreateProductTagDTO[],
@@ -481,7 +409,7 @@ export default class ProductModuleService<
sharedContext
)
return await this.baseRepository_.serialize(productTags, { populate: true })
return await this.baseRepository_.serialize(productTags)
}
@InjectTransactionManager("baseRepository_")
@@ -494,7 +422,7 @@ export default class ProductModuleService<
sharedContext
)
return await this.baseRepository_.serialize(productTags, { populate: true })
return await this.baseRepository_.serialize(productTags)
}
@InjectTransactionManager("baseRepository_")
@@ -549,7 +477,7 @@ export default class ProductModuleService<
const createdOptions = await this.baseRepository_.serialize<
ProductTypes.ProductOptionDTO[]
>(options, { populate: true })
>(options)
return Array.isArray(data) ? createdOptions : createdOptions[0]
}
@@ -642,6 +570,7 @@ export default class ProductModuleService<
): Promise<ProductTypes.ProductOptionDTO[] | ProductTypes.ProductOptionDTO> {
let normalizedInput: UpdateProductOptionInput[] = []
if (isString(idOrSelector)) {
await this.productOptionService_.retrieve(idOrSelector, {}, sharedContext)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const options = await this.productOptionService_.list(
@@ -691,98 +620,11 @@ export default class ProductModuleService<
)
}
const productOptions = await this.diffOptions_(
return await this.productOptionService_.upsertWithReplace(
normalizedInput,
{ relations: ["values"] },
sharedContext
)
return productOptions
}
// TODO: Do validation
@InjectTransactionManager("baseRepository_")
protected async diffOptions_(
data: UpdateProductOptionInput[],
@MedusaContext() sharedContext: Context = {}
) {
const toCreate = data.filter((o) => !o.id)
const toUpdate = data.filter((o) => o.id)
let createdOptions: ProductOption[] = []
let updatedOptions: ProductOption[] = []
if (toCreate.length) {
createdOptions = await this.productOptionService_.create(
toCreate,
sharedContext
)
}
if (toUpdate.length) {
const existingOptions = await this.productOptionService_.list(
{ id: toUpdate.map((o) => o.id) },
{ take: null },
sharedContext
)
const updateOptions = await promiseAll(
toUpdate.map(async (optionToUpdate) => {
const dbOption = existingOptions.find(
(o) => o.id === optionToUpdate.id
)
if (!dbOption) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Option with id "${optionToUpdate.id}" does not exist, but was referenced in the update request`
)
}
if (!optionToUpdate.values) {
return optionToUpdate
}
const valuesToDelete = dbOption.values
.filter((dbVal) => {
return !optionToUpdate.values?.some(
(updateVal) => updateVal.value === dbVal.value
)
})
.map((v) => v.id)
const valuesToUpsert = optionToUpdate.values?.map((val) => {
const dbValue = dbOption.values.find((v) => v.value === val.value)
if (dbValue) {
return {
...val,
id: dbValue.id,
}
}
return val
})
await this.productOptionValueService_.delete({
id: { $in: valuesToDelete },
})
const updatedValues = await this.productOptionValueService_.upsert(
valuesToUpsert,
sharedContext
)
return {
...optionToUpdate,
values: updatedValues,
}
})
)
updatedOptions = await this.productOptionService_.update(
updateOptions,
sharedContext
)
}
return [...createdOptions, ...updatedOptions]
}
createCollections(
@@ -809,7 +651,7 @@ export default class ProductModuleService<
const createdCollections = await this.baseRepository_.serialize<
ProductTypes.ProductCollectionDTO[]
>(collections, { populate: true })
>(collections)
await this.eventBusModuleService_?.emit<ProductCollectionEventData>(
collections.map(({ id }) => ({
@@ -827,11 +669,12 @@ export default class ProductModuleService<
@MedusaContext() sharedContext: Context = {}
): Promise<TProductCollection[]> {
const normalizedInput = data.map(
ProductModuleService.normalizeProductCollectionInput
ProductModuleService.normalizeCreateProductCollectionInput
)
return await this.productCollectionService_.create(
return await this.productCollectionService_.upsertWithReplace(
normalizedInput,
{ relations: ["products"] },
sharedContext
)
}
@@ -919,6 +762,11 @@ export default class ProductModuleService<
> {
let normalizedInput: UpdateCollectionInput[] = []
if (isString(idOrSelector)) {
await this.productCollectionService_.retrieve(
idOrSelector,
{},
sharedContext
)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const collections = await this.productCollectionService_.list(
@@ -958,11 +806,12 @@ export default class ProductModuleService<
@MedusaContext() sharedContext: Context = {}
): Promise<TProductCollection[]> {
const normalizedInput = data.map(
ProductModuleService.normalizeProductCollectionInput
ProductModuleService.normalizeUpdateProductCollectionInput
)
return await this.productCollectionService_.update(
return await this.productCollectionService_.upsertWithReplace(
normalizedInput,
{ relations: ["products"] },
sharedContext
)
}
@@ -1041,7 +890,7 @@ export default class ProductModuleService<
const createdProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products, { populate: true })
>(products)
await this.eventBusModuleService_?.emit<ProductEventData>(
createdProducts.map(({ id }) => ({
@@ -1087,7 +936,7 @@ export default class ProductModuleService<
const result = [...created, ...updated]
const allProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[] | ProductTypes.ProductDTO
>(result, { populate: true })
>(result)
if (created.length) {
await this.eventBusModuleService_?.emit<ProductEventData>(
@@ -1129,6 +978,9 @@ export default class ProductModuleService<
): Promise<ProductTypes.ProductDTO[] | ProductTypes.ProductDTO> {
let normalizedInput: UpdateProductInput[] = []
if (isString(idOrSelector)) {
// This will throw if the product does not exist
await this.productService_.retrieve(idOrSelector, {}, sharedContext)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const products = await this.productService_.list(
@@ -1147,7 +999,7 @@ export default class ProductModuleService<
const updatedProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products, { populate: true })
>(products)
await this.eventBusModuleService_?.emit<ProductEventData>(
updatedProducts.map(({ id }) => ({
@@ -1159,14 +1011,6 @@ export default class ProductModuleService<
return isString(idOrSelector) ? updatedProducts[0] : updatedProducts
}
// Orchestrate product creation (and updates follow a similar logic). For each product:
// 1. Create the base product
// 2. Upsert images, assign to product
// 3. Upsert tags, assign to product
// 4. Upsert product type, assign to product
// 5. Create options and option values
// 6. Assign options to variants
// 7. Create variants
@InjectTransactionManager("baseRepository_")
protected async create_(
data: ProductTypes.CreateProductDTO[],
@@ -1175,61 +1019,52 @@ export default class ProductModuleService<
const normalizedInput = data.map(
ProductModuleService.normalizeCreateProductInput
)
const productsData = await promiseAll(
normalizedInput.map(async (product: any) => {
const productData = { ...product }
if (productData.images?.length) {
productData.images = await this.productImageService_.upsert(
productData.images,
sharedContext
)
const productData = await this.productService_.upsertWithReplace(
normalizedInput,
{
relations: ["type", "collection", "images", "tags", "categories"],
},
sharedContext
)
await promiseAll(
// Note: It's safe to rely on the order here as `upsertWithReplace` preserves the order of the input
normalizedInput.map(async (product, i) => {
const upsertedProduct: any = productData[i]
upsertedProduct.options = []
upsertedProduct.variants = []
if (product.options?.length) {
upsertedProduct.options =
await this.productOptionService_.upsertWithReplace(
product.options?.map((option) => ({
...option,
product_id: upsertedProduct.id,
})) ?? [],
{ relations: ["values"] },
sharedContext
)
}
if (productData.tags?.length) {
productData.tags = await this.productTagService_.upsert(
productData.tags,
sharedContext
)
if (product.variants?.length) {
upsertedProduct.variants =
await this.productVariantService_.upsertWithReplace(
ProductModuleService.assignOptionsToVariants(
product.variants?.map((v) => ({
...v,
product_id: upsertedProduct.id,
})) ?? [],
upsertedProduct.options
),
{ relations: ["options"] },
sharedContext
)
}
if (productData.type) {
productData.type = await this.productTypeService_.upsert(
productData.type,
sharedContext
)
}
// This is not the cleanest solution, but it's the easiest way to reassign categories for now
if (productData.categories) {
productData.categories = await this.productCategoryService_.list(
{ id: productData.categories.map((c) => c.id) },
{ take: null },
sharedContext
)
}
if (productData.options?.length) {
productData.options = await this.productOptionService_.create(
productData.options,
sharedContext
)
}
if (productData.variants?.length) {
productData.variants = await this.productVariantService_.create(
ProductModuleService.assignOptionsToVariants(
productData.variants!,
productData.options
),
sharedContext
)
}
return productData as ProductTypes.CreateProductDTO
})
)
return await this.productService_.create(productsData, sharedContext)
return productData
}
@InjectTransactionManager("baseRepository_")
@@ -1240,122 +1075,117 @@ export default class ProductModuleService<
const normalizedInput = data.map(
ProductModuleService.normalizeUpdateProductInput
)
const productsData = await promiseAll(
normalizedInput.map(async (product: any) => {
const productData = { ...product }
// TODO: We don't remove images, tags, and types as they can exist independently. However, how do we handle orphaned entities?
if (productData.images) {
productData.images = await this.productImageService_.upsert(
productData.images,
const productData = await this.productService_.upsertWithReplace(
normalizedInput,
{
relations: ["type", "collection", "images", "tags", "categories"],
},
sharedContext
)
// There is more than 1-level depth of relations here, so we need to handle the options and variants manually
await promiseAll(
// Note: It's safe to rely on the order here as `upsertWithReplace` preserves the order of the input
normalizedInput.map(async (product, i) => {
const upsertedProduct: any = productData[i]
let allOptions: any[] = []
if (product.options?.length) {
upsertedProduct.options =
await this.productOptionService_.upsertWithReplace(
product.options?.map((option) => ({
...option,
product_id: upsertedProduct.id,
})) ?? [],
{ relations: ["values"] },
sharedContext
)
// Since we handle the options and variants outside of the product upsert, we need to clean up manuallys
await this.productOptionService_.delete(
{
product_id: upsertedProduct.id,
id: {
$nin: upsertedProduct.options.map(({ id }) => id),
},
},
sharedContext
)
allOptions = upsertedProduct.options
} else {
// If the options weren't affected, but the user is changing the variants, make sure we have all options available locally
if (product.variants?.length) {
allOptions = await this.productOptionService_.list(
{ product_id: upsertedProduct.id },
{ take: null },
sharedContext
)
}
}
if (product.variants?.length) {
upsertedProduct.variants =
await this.productVariantService_.upsertWithReplace(
ProductModuleService.assignOptionsToVariants(
product.variants?.map((v) => ({
...v,
product_id: upsertedProduct.id,
})) ?? [],
allOptions
),
{ relations: ["options"] },
sharedContext
)
await this.productVariantService_.delete(
{
product_id: upsertedProduct.id,
id: {
$nin: upsertedProduct.variants.map(({ id }) => id),
},
},
sharedContext
)
}
if (productData.tags) {
productData.tags = await this.productTagService_.upsert(
productData.tags,
sharedContext
)
}
if (productData.type) {
productData.type = await this.productTypeService_.upsert(
productData.type,
sharedContext
)
}
// This is not the cleanest solution, but it's the easiest way to reassign categories for now
if (productData.categories) {
productData.categories = await this.productCategoryService_.list(
{ id: productData.categories.map((c) => c.id) },
{ take: null },
sharedContext
)
}
// TODO: Maybe we also want to delete the options and variants that are not in the list?
if (productData.options) {
productData.options = await this.diffOptions_(
productData.options,
sharedContext
)
}
if (productData.variants) {
const dbOptionsForProduct = await this.productOptionService_.list(
{ product_id: productData.id },
{ take: null },
sharedContext
)
// Since the options are not flushed yet, we must do this merge here
const allOptionsForProduct = uniqBy(
[...(productData.options ?? []), ...dbOptionsForProduct],
"id"
)
productData.variants = await this.diffVariants_(
productData.variants,
allOptionsForProduct,
sharedContext
)
}
return productData as UpdateProductInput
})
)
return await this.productService_.update(productsData, sharedContext)
return productData
}
protected static normalizeCreateProductInput(
product: ProductTypes.CreateProductDTO
): ProductTypes.CreateProductDTO {
const productData = { ...product }
const productData = ProductModuleService.normalizeUpdateProductInput(
product as UpdateProductInput
) as ProductTypes.CreateProductDTO
if (!productData.handle && productData.title) {
productData.handle = kebabCase(productData.title)
}
if (productData.is_giftcard) {
productData.discountable = false
}
if (!productData.status) {
productData.status = ProductStatus.DRAFT
}
if (!productData.thumbnail && productData.images?.length) {
productData.thumbnail = isString(productData.images[0])
? (productData.images[0] as string)
: (productData.images[0] as { url: string }).url
productData.thumbnail = productData.images[0].url
}
return ProductModuleService.normalizeUpdateProductInput(
productData
) as ProductTypes.CreateProductDTO
return productData
}
protected static normalizeUpdateProductInput(
product: ProductTypes.UpdateProductDTO
): ProductTypes.UpdateProductDTO {
product: UpdateProductInput
): UpdateProductInput {
const productData = { ...product }
if (productData.is_giftcard) {
productData.discountable = false
}
if (productData.images?.length) {
productData.images = productData.images?.map((image) => {
if (isString(image)) {
return { url: image }
}
return image
})
}
if (productData.options?.length) {
productData.options = productData.options?.map((option) => {
;(productData as any).options = productData.options?.map((option) => {
return {
title: option.title,
values: option.values?.map((value) => {
@@ -1370,12 +1200,31 @@ export default class ProductModuleService<
return productData
}
protected static normalizeProductCollectionInput(
protected static normalizeCreateProductCollectionInput(
collection: ProductTypes.CreateProductCollectionDTO
): ProductTypes.CreateProductCollectionDTO {
const collectionData =
ProductModuleService.normalizeUpdateProductCollectionInput(
collection
) as ProductTypes.CreateProductCollectionDTO
if (!collectionData.handle && collectionData.title) {
collectionData.handle = kebabCase(collectionData.title)
}
return collectionData
}
protected static normalizeUpdateProductCollectionInput(
collection: ProductTypes.CreateProductCollectionDTO | UpdateCollectionInput
): ProductTypes.CreateProductCollectionDTO | UpdateCollectionInput {
const collectionData = { ...collection }
if (collectionData.product_ids?.length) {
;(collectionData as any).products = collectionData.product_ids
;(collectionData as any).products = collectionData.product_ids.map(
(pid) => ({
id: pid,
})
)
delete collectionData.product_ids
}
@@ -1390,12 +1239,16 @@ export default class ProductModuleService<
):
| ProductTypes.CreateProductVariantDTO[]
| ProductTypes.UpdateProductVariantDTO[] {
if (!variants.length) {
return variants
}
const variantsWithOptions = variants.map((variant: any) => {
const variantOptions = Object.entries(variant.options ?? {}).map(
([key, val]) => {
const option = options.find((o) => o.title === key)
const optionValue = option?.values?.find(
(v: any) => (v.value.value ?? v.value) === val
(v: any) => (v.value?.value ?? v.value) === val
)
if (!optionValue) {
@@ -1421,11 +1274,3 @@ export default class ProductModuleService<
return variantsWithOptions
}
}
const uniqBy = <T>(arr: T[], key: keyof T) => {
const seen = new Set()
return arr.filter((item) => {
const k = item[key]
return seen.has(k) ? false : seen.add(k)
})
}

View File

@@ -1,4 +1,10 @@
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import {
Context,
DAL,
FindConfig,
ProductTypes,
BaseFilterable,
} from "@medusajs/types"
import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils"
import { Product } from "@models"
@@ -6,6 +12,12 @@ type InjectedDependencies = {
productRepository: DAL.RepositoryService
}
type NormalizedFilterableProductProps = ProductTypes.FilterableProductProps & {
categories?: {
id: string | { $in: string[] }
}
}
export default class ProductService<
TEntity extends Product = Product
> extends ModulesSdkUtils.internalModuleServiceFactory<InjectedDependencies>(
@@ -27,20 +39,11 @@ export default class ProductService<
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
if (filters.category_id) {
if (Array.isArray(filters.category_id)) {
filters.categories = {
id: { $in: filters.category_id },
}
} else {
filters.categories = {
id: filters.category_id,
}
}
delete filters.category_id
}
return await super.list(filters, config, sharedContext)
return await super.list(
ProductService.normalizeFilters(filters),
config,
sharedContext
)
}
@InjectManager("productRepository_")
@@ -49,6 +52,16 @@ export default class ProductService<
config: FindConfig<any> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
return await super.listAndCount(
ProductService.normalizeFilters(filters),
config,
sharedContext
)
}
protected static normalizeFilters(
filters: NormalizedFilterableProductProps = {}
): NormalizedFilterableProductProps {
if (filters.category_id) {
if (Array.isArray(filters.category_id)) {
filters.categories = {
@@ -56,12 +69,12 @@ export default class ProductService<
}
} else {
filters.categories = {
id: filters.category_id,
id: filters.category_id as string,
}
}
delete filters.category_id
}
return await super.listAndCount(filters, config, sharedContext)
return filters
}
}

View File

@@ -25,10 +25,6 @@ export enum ProductEvents {
PRODUCT_DELETED = "product.deleted",
}
export type UpdateProductInput = ProductTypes.UpdateProductDTO & {
id: string
}
export type ProductCollectionEventData = {
id: string
}
@@ -39,6 +35,10 @@ export enum ProductCollectionEvents {
COLLECTION_DELETED = "product-collection.deleted",
}
export type UpdateProductInput = ProductTypes.UpdateProductDTO & {
id: string
}
export type UpdateProductCollection =
ProductTypes.UpdateProductCollectionDTO & {
products?: string[]
@@ -55,7 +55,7 @@ export type UpdateCollectionInput = ProductTypes.UpdateProductCollectionDTO & {
export type UpdateProductVariantInput = ProductTypes.UpdateProductVariantDTO & {
id: string
product_id?: string
product_id?: string | null
}
export type UpdateProductOptionInput = ProductTypes.UpdateProductOptionDTO & {

View File

@@ -1,5 +1,8 @@
{
"extends": "./tsconfig.json",
"include": ["src", "integration-tests"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"sourceMap": true
}
}

View File

@@ -86,7 +86,11 @@ export interface ProductDTO {
*
* @expandable
*/
collection: ProductCollectionDTO
collection?: ProductCollectionDTO | null
/**
* The associated product collection id.
*/
collection_id?: string | null
/**
* The associated product categories.
*
@@ -98,7 +102,11 @@ export interface ProductDTO {
*
* @expandable
*/
type: ProductTypeDTO
type?: ProductTypeDTO | null
/**
* The associated product type id.
*/
type_id?: string | null
/**
* The associated product tags.
*
@@ -239,11 +247,11 @@ export interface ProductVariantDTO {
*
* @expandable
*/
product: ProductDTO
product?: ProductDTO | null
/**
* The ID of the associated product.
* The associated product id.
*/
product_id: string
product_id?: string | null
/**
* he ranking of the variant among other variants associated with the product.
*/
@@ -301,7 +309,11 @@ export interface ProductCategoryDTO {
*
* @expandable
*/
parent_category?: ProductCategoryDTO
parent_category?: ProductCategoryDTO | null
/**
* The associated parent category id.
*/
parent_category_id?: string | null
/**
* The associated child categories.
*
@@ -438,6 +450,14 @@ export interface ProductCollectionDTO {
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown> | null
/**
* When the product collection was created.
*/
created_at: string | Date
/**
* When the product collection was updated.
*/
updated_at: string | Date
/**
* When the product collection was deleted.
*/
@@ -468,6 +488,14 @@ export interface ProductTypeDTO {
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown> | null
/**
* When the product type was created.
*/
created_at: string | Date
/**
* When the product type was updated.
*/
updated_at: string | Date
/**
* When the product type was deleted.
*/
@@ -494,7 +522,11 @@ export interface ProductOptionDTO {
*
* @expandable
*/
product: ProductDTO
product?: ProductDTO | null
/**
* The associated product id.
*/
product_id?: string | null
/**
* The associated product option values.
*
@@ -505,6 +537,14 @@ export interface ProductOptionDTO {
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown> | null
/**
* When the product option was created.
*/
created_at: string | Date
/**
* When the product option was updated.
*/
updated_at: string | Date
/**
* When the product option was deleted.
*/
@@ -521,13 +561,21 @@ export interface ProductVariantOptionDTO {
*
* @expandable
*/
option_value: ProductOptionValueDTO
option_value?: ProductOptionValueDTO | null
/**
* The value of the product variant option id.
*/
option_value_id?: string | null
/**
* The associated product variant.
*
* @expandable
*/
variant: ProductVariantDTO
variant?: ProductVariantDTO | null
/**
* The associated product variant id.
*/
variant_id?: string | null
}
/**
@@ -553,6 +601,14 @@ export interface ProductImageDTO {
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown> | null
/**
* When the product image was created.
*/
created_at: string | Date
/**
* When the product image was updated.
*/
updated_at: string | Date
/**
* When the product image was deleted.
*/
@@ -585,11 +641,23 @@ export interface ProductOptionValueDTO {
*
* @expandable
*/
option: ProductOptionDTO
option?: ProductOptionDTO | null
/**
* The associated product option id.
*/
option_id?: string | null
/**
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown> | null
/**
* When the product option value was created.
*/
created_at: string | Date
/**
* When the product option value was updated.
*/
updated_at: string | Date
/**
* When the product option value was deleted.
*/
@@ -644,23 +712,6 @@ export interface FilterableProductProps
*/
value?: string[]
}
/**
* Filters on a product's categories.
*/
categories?: {
/**
* IDs to filter categories by.
*/
id?: string | string[] | OperatorMap<string>
/**
* Filter categories by whether they're internal
*/
is_internal?: boolean
/**
* Filter categories by whether they're active.
*/
is_active?: boolean
}
/**
* Filter a product by the ID of the associated type
*/
@@ -921,10 +972,6 @@ export interface UpdateProductCollectionDTO {
* A product type to create.
*/
export interface CreateProductTypeDTO {
/**
* The product type's ID.
*/
id?: string
/**
* The product type's value.
*/
@@ -935,13 +982,11 @@ export interface CreateProductTypeDTO {
metadata?: Record<string, unknown>
}
export interface UpsertProductTypeDTO {
id?: string
value: string
export interface UpsertProductTypeDTO extends UpdateProductTypeDTO {
/**
* Holds custom data in key-value pairs.
* The product type's ID.
*/
metadata?: Record<string, unknown>
id?: string
}
/**
@@ -950,10 +995,6 @@ export interface UpsertProductTypeDTO {
* The data to update in a product type. The `id` is used to identify which product type to update.
*/
export interface UpdateProductTypeDTO {
/**
* The ID of the product type to update.
*/
id: string
/**
* The new value of the product type.
*/
@@ -964,6 +1005,45 @@ export interface UpdateProductTypeDTO {
metadata?: Record<string, unknown>
}
/**
* @interface
*
* A product image to create.
*/
export interface CreateProductImageDTO {
/**
* The product image's URL.
*/
url: string
/**
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown>
}
export interface UpsertProductImageDTO extends UpdateProductImageDTO {
/**
* The product image's ID.
*/
id?: string
}
/**
* @interface
*
* The data to update in a product image. The `id` is used to identify which product image to update.
*/
export interface UpdateProductImageDTO {
/**
* The new URL of the product image.
*/
url?: string
/**
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown>
}
/**
* @interface
*
@@ -976,9 +1056,11 @@ export interface CreateProductTagDTO {
value: string
}
export interface UpsertProductTagDTO {
export interface UpsertProductTagDTO extends UpdateProductTagDTO {
/**
* The ID of the product tag to update.
*/
id?: string
value: string
}
/**
@@ -988,10 +1070,6 @@ export interface UpsertProductTagDTO {
* The data to update in a product tag. The `id` is used to identify which product tag to update.
*/
export interface UpdateProductTagDTO {
/**
* The ID of the product tag to update.
*/
id: string
/**
* The value of the product tag.
*/
@@ -1011,7 +1089,7 @@ export interface CreateProductOptionDTO {
/**
* The product option values.
*/
values: string[] | { value: string }[]
values: string[]
/**
* The ID of the associated product.
*/
@@ -1019,12 +1097,24 @@ export interface CreateProductOptionDTO {
}
export interface UpsertProductOptionDTO extends UpdateProductOptionDTO {
/**
* The ID of the product option to update.
*/
id?: string
}
export interface UpdateProductOptionDTO {
/**
* The product option's title.
*/
title?: string
values?: string[] | { value: string }[]
/**
* The product option values.
*/
values?: string[]
/**
* The ID of the associated product.
*/
product_id?: string
}
@@ -1225,11 +1315,6 @@ export interface CreateProductDTO {
* Whether the product can be discounted.
*/
discountable?: boolean
/**
* The product's images. If an array of strings is supplied, each string will be a URL and a `ProductImage` will be created
* and associated with the product. If an array of objects is supplied, you can pass along the ID of an existing `ProductImage`.
*/
images?: string[] | { id?: string; url: string }[]
/**
* The URL of the product's thumbnail.
*/
@@ -1244,25 +1329,25 @@ export interface CreateProductDTO {
*/
status?: ProductStatus
/**
* The product type to create and associate with the product.
* The product's images to upsert and associate with the product
*/
type?: CreateProductTypeDTO
images?: UpsertProductImageDTO[]
/**
* The product type to be associated with the product.
* The product type id to associate with the product.
*/
type_id?: string | null
type_id?: string
/**
* The product collection to be associated with the product.
* The product collection to associate with the product.
*/
collection_id?: string | null
collection_id?: string
/**
* The product tags to be created and associated with the product.
* The product tags to be upserted and associated with the product.
*/
tags?: CreateProductTagDTO[]
tags?: UpsertProductTagDTO[]
/**
* The product categories to associate with the product.
*/
categories?: { id: string }[]
category_ids?: string[]
/**
* The product options to be created and associated with the product.
*/
@@ -1342,11 +1427,6 @@ export interface UpdateProductDTO {
* Whether the product can be discounted.
*/
discountable?: boolean
/**
* The product's images. If an array of strings is supplied, each string will be a URL and a `ProductImage` will be created
* and associated with the product. If an array of objects is supplied, you can pass along the ID of an existing `ProductImage`.
*/
images?: string[] | { id?: string; url: string }[]
/**
* The URL of the product's thumbnail.
*/
@@ -1361,29 +1441,29 @@ export interface UpdateProductDTO {
*/
status?: ProductStatus
/**
* The product type to create and associate with the product.
* The product's images to upsert and associate with the product
*/
type?: CreateProductTypeDTO
images?: UpsertProductImageDTO[]
/**
* The product type to be associated with the product.
* The product type to associate with the product.
*/
type_id?: string | null
/**
* The product collection to be associated with the product.
* The product collection to associate with the product.
*/
collection_id?: string | null
/**
* The product tags to be created and associated with the product.
*/
tags?: CreateProductTagDTO[]
tags?: UpsertProductTagDTO[]
/**
* The product categories to associate with the product.
*/
categories?: { id: string }[]
category_ids?: string[]
/**
* The product options to be created and associated with the product.
*/
options?: CreateProductOptionDTO[]
options?: UpsertProductOptionDTO[]
/**
* The product variants to be created and associated with the product. You can also update existing product variants associated with the product.
*/

View File

@@ -305,6 +305,267 @@ export interface IProductModuleService extends IModuleService {
sharedContext?: Context
): Promise<[ProductDTO[], number]>
/**
* This method is used to create a list of products.
*
* @param {CreateProductDTO[]} data - The products to be created.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO[]>} The list of created products.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function createProduct (title: string) {
* const productModule = await initializeProductModule()
*
* const products = await productModule.create([
* {
* title
* }
* ])
*
* // do something with the products or return them
* }
*/
create(
data: CreateProductDTO[],
sharedContext?: Context
): Promise<ProductDTO[]>
/**
* This method is used to create a product.
*
* @param {CreateProductDTO} data - The product to be created.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO>} The created product.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function createProduct (title: string) {
* const productModule = await initializeProductModule()
*
* const product = await productModule.create(
* {
* title
* }
* )
*
* // do something with the product or return it
* }
*/
create(data: CreateProductDTO, sharedContext?: Context): Promise<ProductDTO>
/**
* This method updates existing products, or creates new ones if they don't exist.
*
* @param {CreateProductDTO[]} data - The attributes to update or create for each product.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO[]>} The updated and created products.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function upserProduct (title: string) {
* const productModule = await initializeProductModule()
*
* const createdProducts = await productModule.upsert([
* {
* title
* }
* ])
*
* // do something with the products or return them
* }
*/
upsert(
data: UpsertProductDTO[],
sharedContext?: Context
): Promise<ProductDTO[]>
/**
* This method updates the product if it exists, or creates a new ones if it doesn't.
*
* @param {CreateProductDTO} data - The attributes to update or create for the new product.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO>} The updated or created product.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function upserProduct (title: string) {
* const productModule = await initializeProductModule()
*
* const createdProduct = await productModule.upsert(
* {
* title
* }
* )
*
* // do something with the product or return it
* }
*/
upsert(
data: UpsertProductDTO[],
sharedContext?: Context
): Promise<ProductDTO[]>
/**
* This method is used to update a product.
*
* @param {string} id - The ID of the product to be updated.
* @param {UpdateProductDTO} data - The attributes of the product to be updated
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO>} The updated product.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function updateProduct (id: string, title: string) {
* const productModule = await initializeProductModule()
*
* const product = await productModule.update(id, {
* title
* }
* )
*
* // do something with the product or return it
* }
*/
update(
id: string,
data: UpdateProductDTO,
sharedContext?: Context
): Promise<ProductDTO>
/**
* This method is used to update a list of products determined by the selector filters.
*
* @param {FilterableProductProps} selector - The filters that will determine which products will be updated.
* @param {UpdateProductDTO} data - The attributes to be updated on the selected products
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO[]>} The updated products.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function updateProduct (id: string, title: string) {
* const productModule = await initializeProductModule()
*
* const products = await productModule.update({id}, {
* title
* }
* )
*
* // do something with the products or return them
* }
*/
update(
selector: FilterableProductProps,
data: UpdateProductDTO,
sharedContext?: Context
): Promise<ProductDTO[]>
/**
* This method is used to delete products. Unlike the {@link softDelete} method, this method will completely remove the products and they can no longer be accessed or retrieved.
*
* @param {string[]} productIds - The IDs of the products to be deleted.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<void>} Resolves when the products are successfully deleted.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function deleteProducts (ids: string[]) {
* const productModule = await initializeProductModule()
*
* await productModule.delete(ids)
* }
*/
delete(productIds: string[], sharedContext?: Context): Promise<void>
/**
* This method is used to delete products. Unlike the {@link delete} method, this method won't completely remove the product. It can still be accessed or retrieved using methods like {@link retrieve} if you pass the `withDeleted` property to the `config` object parameter.
*
* The soft-deleted products can be restored using the {@link restore} method.
*
* @param {string[]} productIds - The IDs of the products to soft-delete.
* @param {SoftDeleteReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to soft delete along with the each of the products. You can pass to its `returnLinkableKeys`
* property any of the product's relation attribute names, such as `variant_id`.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were also soft deleted, such as the ID of associated product variants. The object's keys are the ID attribute names of the product entity's relations, such as `variant_id`, and its value is an array of strings, each being the ID of a record associated with the product through this relation, such as the IDs of associated product variants.
*
* If there are no related records, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function deleteProducts (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const cascadedEntities = await productModule.softDelete(ids)
*
* // do something with the returned cascaded entity IDs or return them
* }
*/
softDelete<TReturnableLinkableKeys extends string = string>(
productIds: string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to restore products which were deleted using the {@link softDelete} method.
*
* @param {string[]} productIds - The IDs of the products to restore.
* @param {RestoreReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to restore along with each of the products. You can pass to its `returnLinkableKeys`
* property any of the product's relation attribute names, such as `variant_id`.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were restored, such as the ID of associated product variants. The object's keys are the ID attribute names of the product entity's relations, such as `variant_id`, and its value is an array of strings, each being the ID of the record associated with the product through this relation, such as the IDs of associated product variants.
*
* If there are no related records that were restored, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function restoreProducts (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const cascadedEntities = await productModule.restore(ids, {
* returnLinkableKeys: ["variant_id"]
* })
*
* // do something with the returned cascaded entity IDs or return them
* }
*/
restore<TReturnableLinkableKeys extends string = string>(
productIds: string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to retrieve a tag by its ID.
*
@@ -1679,6 +1940,112 @@ export interface IProductModuleService extends IModuleService {
sharedContext?: Context
): Promise<ProductVariantDTO[]>
/**
* This method is used to retrieve a paginated list of product variants along with the total count of available product variants satisfying the provided filters.
*
* @param {FilterableProductVariantProps} filters - The filters applied on the retrieved product variants.
* @param {FindConfig<ProductVariantDTO>} config -
* The configurations determining how the product variants are retrieved. Its properties, such as `select` or `relations`, accept the
* attributes or relations associated with a product variant.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<[ProductVariantDTO[], number]>} The list of product variants along with their total count.
*
* @example
* To retrieve a list of product variants using their IDs:
*
* ```ts
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function retrieveProductVariants (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const [variants, count] = await productModule.listAndCountVariants({
* id: ids
* })
*
* // do something with the product variants or return them
* }
* ```
*
* To specify relations that should be retrieved within the product variants:
*
* ```ts
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function retrieveProductVariants (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const [variants, count] = await productModule.listAndCountVariants({
* id: ids
* }, {
* relations: ["options"]
* })
*
* // do something with the product variants or return them
* }
* ```
*
* By default, only the first `15` records are retrieved. You can control pagination by specifying the `skip` and `take` properties of the `config` parameter:
*
* ```ts
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function retrieveProductVariants (ids: string[], skip: number, take: number) {
* const productModule = await initializeProductModule()
*
* const [variants, count] = await productModule.listAndCountVariants({
* id: ids
* }, {
* relations: ["options"],
* skip,
* take
* })
*
* // do something with the product variants or return them
* }
* ```
*
* You can also use the `$and` or `$or` properties of the `filter` parameter to use and/or conditions in your filters. For example:
*
* ```ts
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function retrieveProductVariants (ids: string[], sku: string, skip: number, take: number) {
* const productModule = await initializeProductModule()
*
* const [variants, count] = await productModule.listAndCountVariants({
* $and: [
* {
* id: ids
* },
* {
* sku
* }
* ]
* }, {
* relations: ["options"],
* skip,
* take
* })
*
* // do something with the product variants or return them
* }
* ```
*/
listAndCountVariants(
filters?: FilterableProductVariantProps,
config?: FindConfig<ProductVariantDTO>,
sharedContext?: Context
): Promise<[ProductVariantDTO[], number]>
/**
* This method is used to create product variants.
*
@@ -1880,112 +2247,6 @@ export interface IProductModuleService extends IModuleService {
sharedContext?: Context
): Promise<void>
/**
* This method is used to retrieve a paginated list of product variants along with the total count of available product variants satisfying the provided filters.
*
* @param {FilterableProductVariantProps} filters - The filters applied on the retrieved product variants.
* @param {FindConfig<ProductVariantDTO>} config -
* The configurations determining how the product variants are retrieved. Its properties, such as `select` or `relations`, accept the
* attributes or relations associated with a product variant.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<[ProductVariantDTO[], number]>} The list of product variants along with their total count.
*
* @example
* To retrieve a list of product variants using their IDs:
*
* ```ts
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function retrieveProductVariants (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const [variants, count] = await productModule.listAndCountVariants({
* id: ids
* })
*
* // do something with the product variants or return them
* }
* ```
*
* To specify relations that should be retrieved within the product variants:
*
* ```ts
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function retrieveProductVariants (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const [variants, count] = await productModule.listAndCountVariants({
* id: ids
* }, {
* relations: ["options"]
* })
*
* // do something with the product variants or return them
* }
* ```
*
* By default, only the first `15` records are retrieved. You can control pagination by specifying the `skip` and `take` properties of the `config` parameter:
*
* ```ts
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function retrieveProductVariants (ids: string[], skip: number, take: number) {
* const productModule = await initializeProductModule()
*
* const [variants, count] = await productModule.listAndCountVariants({
* id: ids
* }, {
* relations: ["options"],
* skip,
* take
* })
*
* // do something with the product variants or return them
* }
* ```
*
* You can also use the `$and` or `$or` properties of the `filter` parameter to use and/or conditions in your filters. For example:
*
* ```ts
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function retrieveProductVariants (ids: string[], sku: string, skip: number, take: number) {
* const productModule = await initializeProductModule()
*
* const [variants, count] = await productModule.listAndCountVariants({
* $and: [
* {
* id: ids
* },
* {
* sku
* }
* ]
* }, {
* relations: ["options"],
* skip,
* take
* })
*
* // do something with the product variants or return them
* }
* ```
*/
listAndCountVariants(
filters?: FilterableProductVariantProps,
config?: FindConfig<ProductVariantDTO>,
sharedContext?: Context
): Promise<[ProductVariantDTO[], number]>
/**
* This method is used to delete variants. Unlike the {@link delete} method, this method won't completely remove the variant. It can still be accessed or retrieved using methods like {@link retrieve} if you pass the `withDeleted` property to the `config` object parameter.
*
@@ -2519,6 +2780,74 @@ export interface IProductModuleService extends IModuleService {
sharedContext?: Context
): Promise<void>
/**
* This method is used to delete product collections. Unlike the {@link deleteCollections} method, this method won't completely remove the collection. It can still be accessed or retrieved using methods like {@link retrieveCollections} if you pass the `withDeleted` property to the `config` object parameter.
*
* The soft-deleted collections can be restored using the {@link restoreCollections} method.
*
* @param {string[]} collectionIds - The IDs of the collections to soft-delete.
* @param {SoftDeleteReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to soft delete along with the each of the collections. You can pass to its `returnLinkableKeys`
* property any of the collection's relation attribute names.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were also soft deleted. The object's keys are the ID attribute names of the collection entity's relations.
*
* If there are no related records, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function deleteCollections (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const cascadedEntities = await productModule.softDeleteCollections(ids)
*
* // do something with the returned cascaded entity IDs or return them
* }
*/
softDeleteCollections<TReturnableLinkableKeys extends string = string>(
collectionIds: string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to restore collections which were deleted using the {@link softDelete} method.
*
* @param {string[]} collectionIds - The IDs of the collections to restore.
* @param {RestoreReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to restore along with each of the collections. You can pass to its `returnLinkableKeys`
* property any of the collection's relation attribute names.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were restored. The object's keys are the ID attribute names of the product entity's relations.
*
* If there are no related records that were restored, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function restoreCollections (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const cascadedEntities = await productModule.restoreCollections(ids, {
* returnLinkableKeys: []
* })
*
* // do something with the returned cascaded entity IDs or return them
* }
*/
restoreCollections<TReturnableLinkableKeys extends string = string>(
collectionIds: string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to retrieve a product category by its ID.
*
@@ -2859,365 +3188,4 @@ export interface IProductModuleService extends IModuleService {
* }
*/
deleteCategory(categoryId: string, sharedContext?: Context): Promise<void>
/**
* This method is used to create a list of products.
*
* @param {CreateProductDTO[]} data - The products to be created.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO[]>} The list of created products.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function createProduct (title: string) {
* const productModule = await initializeProductModule()
*
* const products = await productModule.create([
* {
* title
* }
* ])
*
* // do something with the products or return them
* }
*/
create(
data: CreateProductDTO[],
sharedContext?: Context
): Promise<ProductDTO[]>
/**
* This method is used to create a product.
*
* @param {CreateProductDTO} data - The product to be created.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO>} The created product.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function createProduct (title: string) {
* const productModule = await initializeProductModule()
*
* const product = await productModule.create(
* {
* title
* }
* )
*
* // do something with the product or return it
* }
*/
create(data: CreateProductDTO, sharedContext?: Context): Promise<ProductDTO>
/**
* This method updates existing products, or creates new ones if they don't exist.
*
* @param {CreateProductDTO[]} data - The attributes to update or create for each product.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO[]>} The updated and created products.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function upserProduct (title: string) {
* const productModule = await initializeProductModule()
*
* const createdProducts = await productModule.upsert([
* {
* title
* }
* ])
*
* // do something with the products or return them
* }
*/
upsert(
data: UpsertProductDTO[],
sharedContext?: Context
): Promise<ProductDTO[]>
/**
* This method updates the product if it exists, or creates a new ones if it doesn't.
*
* @param {CreateProductDTO} data - The attributes to update or create for the new product.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO>} The updated or created product.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function upserProduct (title: string) {
* const productModule = await initializeProductModule()
*
* const createdProduct = await productModule.upsert(
* {
* title
* }
* )
*
* // do something with the product or return it
* }
*/
upsert(
data: UpsertProductDTO[],
sharedContext?: Context
): Promise<ProductDTO[]>
/**
* This method is used to update a product.
*
* @param {string} id - The ID of the product to be updated.
* @param {UpdateProductDTO} data - The attributes of the product to be updated
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO>} The updated product.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function updateProduct (id: string, title: string) {
* const productModule = await initializeProductModule()
*
* const product = await productModule.update(id, {
* title
* }
* )
*
* // do something with the product or return it
* }
*/
update(
id: string,
data: UpdateProductDTO,
sharedContext?: Context
): Promise<ProductDTO>
/**
* This method is used to update a list of products determined by the selector filters.
*
* @param {FilterableProductProps} selector - The filters that will determine which products will be updated.
* @param {UpdateProductDTO} data - The attributes to be updated on the selected products
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<ProductDTO[]>} The updated products.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function updateProduct (id: string, title: string) {
* const productModule = await initializeProductModule()
*
* const products = await productModule.update({id}, {
* title
* }
* )
*
* // do something with the products or return them
* }
*/
update(
selector: FilterableProductProps,
data: UpdateProductDTO,
sharedContext?: Context
): Promise<ProductDTO[]>
/**
* This method is used to delete products. Unlike the {@link softDelete} method, this method will completely remove the products and they can no longer be accessed or retrieved.
*
* @param {string[]} productIds - The IDs of the products to be deleted.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<void>} Resolves when the products are successfully deleted.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function deleteProducts (ids: string[]) {
* const productModule = await initializeProductModule()
*
* await productModule.delete(ids)
* }
*/
delete(productIds: string[], sharedContext?: Context): Promise<void>
/**
* This method is used to delete products. Unlike the {@link delete} method, this method won't completely remove the product. It can still be accessed or retrieved using methods like {@link retrieve} if you pass the `withDeleted` property to the `config` object parameter.
*
* The soft-deleted products can be restored using the {@link restore} method.
*
* @param {string[]} productIds - The IDs of the products to soft-delete.
* @param {SoftDeleteReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to soft delete along with the each of the products. You can pass to its `returnLinkableKeys`
* property any of the product's relation attribute names, such as `variant_id`.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were also soft deleted, such as the ID of associated product variants. The object's keys are the ID attribute names of the product entity's relations, such as `variant_id`, and its value is an array of strings, each being the ID of a record associated with the product through this relation, such as the IDs of associated product variants.
*
* If there are no related records, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function deleteProducts (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const cascadedEntities = await productModule.softDelete(ids)
*
* // do something with the returned cascaded entity IDs or return them
* }
*/
softDelete<TReturnableLinkableKeys extends string = string>(
productIds: string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to restore products which were deleted using the {@link softDelete} method.
*
* @param {string[]} productIds - The IDs of the products to restore.
* @param {RestoreReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to restore along with each of the products. You can pass to its `returnLinkableKeys`
* property any of the product's relation attribute names, such as `variant_id`.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were restored, such as the ID of associated product variants. The object's keys are the ID attribute names of the product entity's relations, such as `variant_id`, and its value is an array of strings, each being the ID of the record associated with the product through this relation, such as the IDs of associated product variants.
*
* If there are no related records that were restored, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function restoreProducts (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const cascadedEntities = await productModule.restore(ids, {
* returnLinkableKeys: ["variant_id"]
* })
*
* // do something with the returned cascaded entity IDs or return them
* }
*/
restore<TReturnableLinkableKeys extends string = string>(
productIds: string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to delete product collections. Unlike the {@link deleteCollections} method, this method won't completely remove the collection. It can still be accessed or retrieved using methods like {@link retrieveCollections} if you pass the `withDeleted` property to the `config` object parameter.
*
* The soft-deleted collections can be restored using the {@link restoreCollections} method.
*
* @param {string[]} collectionIds - The IDs of the collections to soft-delete.
* @param {SoftDeleteReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to soft delete along with the each of the collections. You can pass to its `returnLinkableKeys`
* property any of the collection's relation attribute names.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were also soft deleted. The object's keys are the ID attribute names of the collection entity's relations.
*
* If there are no related records, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function deleteCollections (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const cascadedEntities = await productModule.softDeleteCollections(ids)
*
* // do something with the returned cascaded entity IDs or return them
* }
*/
softDeleteCollections<TReturnableLinkableKeys extends string = string>(
collectionIds: string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to restore collections which were deleted using the {@link softDelete} method.
*
* @param {string[]} collectionIds - The IDs of the collections to restore.
* @param {RestoreReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to restore along with each of the collections. You can pass to its `returnLinkableKeys`
* property any of the collection's relation attribute names.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were restored. The object's keys are the ID attribute names of the product entity's relations.
*
* If there are no related records that were restored, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function restoreCollections (ids: string[]) {
* const productModule = await initializeProductModule()
*
* const cascadedEntities = await productModule.restoreCollections(ids, {
* returnLinkableKeys: []
* })
*
* // do something with the returned cascaded entity IDs or return them
* }
*/
restoreCollections<TReturnableLinkableKeys extends string = string>(
collectionIds: string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method is used to restore product varaints that were soft deleted. Product variants are soft deleted when they're not
* provided in a product's details passed to the {@link update} method.
*
* @param {string[]} variantIds - The IDs of the variants to restore.
* @param {RestoreReturn<TReturnableLinkableKeys>} config -
* Configurations determining which relations to restore along with each of the product variants. You can pass to its `returnLinkableKeys`
* property any of the product variant's relation attribute names.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>}
* An object that includes the IDs of related records that were restored. The object's keys are the ID attribute names of the product variant entity's relations
* and its value is an array of strings, each being the ID of the record associated with the product variant through this relation.
*
* If there are no related records that were restored, the promise resolved to `void`.
*
* @example
* import {
* initialize as initializeProductModule,
* } from "@medusajs/product"
*
* async function restoreProductVariants (ids: string[]) {
* const productModule = await initializeProductModule()
*
* await productModule.restoreVariants(ids)
* }
*/
restoreVariants<TReturnableLinkableKeys extends string = string>(
variantIds: string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
}