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 authored and GitHub committed 2024-03-29 09:03:41 +00:00
1 parent e603726985
commit cbb5e6bd99
35 files changed
+1318 -1374

No files matched your search

@@ -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 ?? [
@@ -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: [],
})
})
@@ -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"
)
})
})
@@ -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`
)
})
})
@@ -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,
},
],
@@ -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"] },
@@ -210,6 +210,7 @@ moduleIntegrationTestRunner({
expect(serialized).toEqual({
id: optionId,
title: optionValue,
product_id: null,
})
})
})
@@ -184,8 +184,11 @@ moduleIntegrationTestRunner({
products: [
{
id: "test-1",
collection_id: null,
type_id: null,
},
],
value: "France",
}),
])
})
@@ -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,
},
@@ -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"
}
}
@@ -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;'
)
}
}
@@ -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;');
}
}
@@ -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)
@@ -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)
}
}
@@ -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
}
}
+12 -4
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
}
}
@@ -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
}
}
+13 -6
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
}
}
+29 -14
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)
}
}
@@ -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)
})
}
+30 -17
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
}
}
+5 -5
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 & {
+4 -1
View File
@@ -1,5 +1,8 @@
{
"extends": "./tsconfig.json",
"include": ["src", "integration-tests"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"sourceMap": true
}
}