From b7d9de80ed92e69f8fa003429d72009673d1d8a8 Mon Sep 17 00:00:00 2001 From: willbouch Date: Wed, 22 Oct 2025 14:28:06 -0400 Subject: [PATCH 01/36] chore(product,types): change relationship between product and option to many to many --- packages/core/types/src/product/common.ts | 4 + .../migrations/.snapshot-medusa-product.json | 574 ++++++++++-------- .../src/migrations/Migration20251022153442.ts | 64 ++ .../product/src/models/product-option.ts | 11 +- .../modules/product/src/models/product.ts | 9 +- .../src/services/product-module-service.ts | 52 +- 6 files changed, 419 insertions(+), 295 deletions(-) create mode 100644 packages/modules/product/src/migrations/Migration20251022153442.ts diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index 01c3d9d14b5e1..5370d97914ac6 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -1207,6 +1207,10 @@ export interface CreateProductOptionDTO { * The ID of the associated product. */ product_id?: string + /** + * Whether the product option is exclusive or global. + */ + isExclusive?: boolean } export interface CreateProductOptionValueDTO { diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 63f79f74f9631..63be1603bef3a 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -195,6 +195,7 @@ "id" ], "referencedTableName": "public.product_category", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -308,6 +309,236 @@ "foreignKeys": {}, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "title": { + "name": "title", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "is_exclusive": { + "name": "is_exclusive", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "product_option", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_product_option_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_deleted_at\" ON \"product_option\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "product_option_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "value": { + "name": "value", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "option_id": { + "name": "option_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "product_option_value", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_product_option_value_option_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_value_option_id\" ON \"product_option_value\" (option_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_product_option_value_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_value_deleted_at\" ON \"product_option_value\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_option_value_option_id_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_option_value_option_id_unique\" ON \"product_option_value\" (option_id, value) WHERE deleted_at IS NULL" + }, + { + "keyName": "product_option_value_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "product_option_value_option_id_foreign": { + "constraintName": "product_option_value_option_id_foreign", + "columnNames": [ + "option_id" + ], + "localTableName": "public.product_option_value", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.product_option", + "createForeignKeyConstraint": true, + "deleteRule": "cascade", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, { "columns": { "id": { @@ -808,6 +1039,7 @@ "id" ], "referencedTableName": "public.product_type", + "createForeignKeyConstraint": true, "deleteRule": "set null", "updateRule": "cascade" }, @@ -821,6 +1053,7 @@ "id" ], "referencedTableName": "public.product_collection", + "createForeignKeyConstraint": true, "deleteRule": "set null", "updateRule": "cascade" } @@ -838,8 +1071,8 @@ "nullable": false, "mappedType": "text" }, - "title": { - "name": "title", + "url": { + "name": "url", "type": "text", "unsigned": false, "autoincrement": false, @@ -856,6 +1089,16 @@ "nullable": true, "mappedType": "json" }, + "rank": { + "name": "rank", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "0", + "mappedType": "integer" + }, "product_id": { "name": "product_id", "type": "text", @@ -898,168 +1141,65 @@ "mappedType": "datetime" } }, - "name": "product_option", + "name": "image", "schema": "public", "indexes": [ { - "keyName": "IDX_product_option_product_id", + "keyName": "IDX_image_product_id", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_product_id\" ON \"product_option\" (product_id) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_image_product_id\" ON \"image\" (product_id) WHERE deleted_at IS NULL" }, { - "keyName": "IDX_product_option_deleted_at", + "keyName": "IDX_image_deleted_at", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_deleted_at\" ON \"product_option\" (deleted_at) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_image_deleted_at\" ON \"image\" (deleted_at) WHERE deleted_at IS NULL" }, { - "keyName": "IDX_option_product_id_title_unique", + "keyName": "IDX_product_image_url", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_option_product_id_title_unique\" ON \"product_option\" (product_id, title) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_image_url\" ON \"image\" (url) WHERE deleted_at IS NULL" }, { - "keyName": "product_option_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "product_option_product_id_foreign": { - "constraintName": "product_option_product_id_foreign", - "columnNames": [ - "product_id" - ], - "localTableName": "public.product_option", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.product", - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, - "nativeEnums": {} - }, - { - "columns": { - "id": { - "name": "id", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "value": { - "name": "value", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "json" - }, - "option_id": { - "name": "option_id", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "text" - }, - "created_at": { - "name": "created_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 6, - "default": "now()", - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 6, - "default": "now()", - "mappedType": "datetime" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 6, - "mappedType": "datetime" - } - }, - "name": "product_option_value", - "schema": "public", - "indexes": [ - { - "keyName": "IDX_product_option_value_option_id", + "keyName": "IDX_product_image_rank", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_value_option_id\" ON \"product_option_value\" (option_id) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_image_rank\" ON \"image\" (rank) WHERE deleted_at IS NULL" }, { - "keyName": "IDX_product_option_value_deleted_at", + "keyName": "IDX_product_image_url_rank_product_id", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_value_deleted_at\" ON \"product_option_value\" (deleted_at) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_image_url_rank_product_id\" ON \"image\" (url, rank, product_id) WHERE deleted_at IS NULL" }, { - "keyName": "IDX_option_value_option_id_unique", + "keyName": "IDX_product_image_rank_product_id", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_option_value_option_id_unique\" ON \"product_option_value\" (option_id, value) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_image_rank_product_id\" ON \"image\" (rank, product_id) WHERE deleted_at IS NULL" }, { - "keyName": "product_option_value_pkey", + "keyName": "image_pkey", "columnNames": [ "id" ], @@ -1071,16 +1211,17 @@ ], "checks": [], "foreignKeys": { - "product_option_value_option_id_foreign": { - "constraintName": "product_option_value_option_id_foreign", + "image_product_id_foreign": { + "constraintName": "image_product_id_foreign", "columnNames": [ - "option_id" + "product_id" ], - "localTableName": "public.product_option_value", + "localTableName": "public.image", "referencedColumnNames": [ "id" ], - "referencedTableName": "public.product_option", + "referencedTableName": "public.product", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1089,43 +1230,6 @@ }, { "columns": { - "id": { - "name": "id", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "url": { - "name": "url", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "json" - }, - "rank": { - "name": "rank", - "type": "integer", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "default": "0", - "mappedType": "integer" - }, "product_id": { "name": "product_id", "type": "text", @@ -1135,102 +1239,26 @@ "nullable": false, "mappedType": "text" }, - "created_at": { - "name": "created_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 6, - "default": "now()", - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamptz", + "product_tag_id": { + "name": "product_tag_id", + "type": "text", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "length": 6, - "default": "now()", - "mappedType": "datetime" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 6, - "mappedType": "datetime" + "mappedType": "text" } }, - "name": "image", + "name": "product_tags", "schema": "public", "indexes": [ { - "keyName": "IDX_image_product_id", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_image_product_id\" ON \"image\" (product_id) WHERE deleted_at IS NULL" - }, - { - "keyName": "IDX_image_deleted_at", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_image_deleted_at\" ON \"image\" (deleted_at) WHERE deleted_at IS NULL" - }, - { - "keyName": "IDX_product_image_url", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_image_url\" ON \"image\" (url) WHERE deleted_at IS NULL" - }, - { - "keyName": "IDX_product_image_rank", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_image_rank\" ON \"image\" (rank) WHERE deleted_at IS NULL" - }, - { - "keyName": "IDX_product_image_url_rank_product_id", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_image_url_rank_product_id\" ON \"image\" (url, rank, product_id) WHERE deleted_at IS NULL" - }, - { - "keyName": "IDX_product_image_rank_product_id", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_image_rank_product_id\" ON \"image\" (rank, product_id) WHERE deleted_at IS NULL" - }, - { - "keyName": "image_pkey", + "keyName": "product_tags_pkey", "columnNames": [ - "id" + "product_id", + "product_tag_id" ], - "composite": false, + "composite": true, "constraint": true, "primary": true, "unique": true @@ -1238,16 +1266,31 @@ ], "checks": [], "foreignKeys": { - "image_product_id_foreign": { - "constraintName": "image_product_id_foreign", + "product_tags_product_id_foreign": { + "constraintName": "product_tags_product_id_foreign", "columnNames": [ "product_id" ], - "localTableName": "public.image", + "localTableName": "public.product_tags", "referencedColumnNames": [ "id" ], "referencedTableName": "public.product", + "createForeignKeyConstraint": true, + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "product_tags_product_tag_id_foreign": { + "constraintName": "product_tags_product_tag_id_foreign", + "columnNames": [ + "product_tag_id" + ], + "localTableName": "public.product_tags", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.product_tag", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1265,8 +1308,8 @@ "nullable": false, "mappedType": "text" }, - "product_tag_id": { - "name": "product_tag_id", + "product_option_id": { + "name": "product_option_id", "type": "text", "unsigned": false, "autoincrement": false, @@ -1275,14 +1318,14 @@ "mappedType": "text" } }, - "name": "product_tags", + "name": "product_product_option", "schema": "public", "indexes": [ { - "keyName": "product_tags_pkey", + "keyName": "product_product_option_pkey", "columnNames": [ "product_id", - "product_tag_id" + "product_option_id" ], "composite": true, "constraint": true, @@ -1292,29 +1335,31 @@ ], "checks": [], "foreignKeys": { - "product_tags_product_id_foreign": { - "constraintName": "product_tags_product_id_foreign", + "product_product_option_product_id_foreign": { + "constraintName": "product_product_option_product_id_foreign", "columnNames": [ "product_id" ], - "localTableName": "public.product_tags", + "localTableName": "public.product_product_option", "referencedColumnNames": [ "id" ], "referencedTableName": "public.product", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" }, - "product_tags_product_tag_id_foreign": { - "constraintName": "product_tags_product_tag_id_foreign", + "product_product_option_product_option_id_foreign": { + "constraintName": "product_product_option_product_option_id_foreign", "columnNames": [ - "product_tag_id" + "product_option_id" ], - "localTableName": "public.product_tags", + "localTableName": "public.product_product_option", "referencedColumnNames": [ "id" ], - "referencedTableName": "public.product_tag", + "referencedTableName": "public.product_option", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1369,6 +1414,7 @@ "id" ], "referencedTableName": "public.product", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" }, @@ -1382,6 +1428,7 @@ "id" ], "referencedTableName": "public.product_category", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1686,6 +1733,7 @@ "id" ], "referencedTableName": "public.product", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1740,6 +1788,7 @@ "id" ], "referencedTableName": "public.product_variant", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" }, @@ -1753,6 +1802,7 @@ "id" ], "referencedTableName": "public.product_option_value", + "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } diff --git a/packages/modules/product/src/migrations/Migration20251022153442.ts b/packages/modules/product/src/migrations/Migration20251022153442.ts new file mode 100644 index 0000000000000..59bc924c42ac2 --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20251022153442.ts @@ -0,0 +1,64 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20251022153442 extends Migration { + override async up(): Promise { + this.addSql( + `create table if not exists "product_product_option" ("product_id" text not null, "product_option_id" text not null, constraint "product_product_option_pkey" primary key ("product_id", "product_option_id"));` + ) + + this.addSql( + `alter table if exists "product_product_option" add constraint "product_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_product_option" add constraint "product_product_option_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;` + ) + + // Add is_exclusive column before migrating data + this.addSql( + `alter table if exists "product_option" add column if not exists "is_exclusive" boolean not null default false;` + ) + + // Migrate existing product_id relationships to the join table + this.addSql( + `insert into "product_product_option" ("product_id", "product_option_id") + select "product_id", "id" from "product_option" where "product_id" is not null;` + ) + + // Set is_exclusive to true for all existing options + this.addSql( + `update "product_option" set "is_exclusive" = true where "id" is not null;` + ) + + // Now drop the old product_id column and constraints + this.addSql( + `alter table if exists "product_option" drop constraint if exists "product_option_product_id_foreign";` + ) + + this.addSql(`drop index if exists "IDX_product_option_product_id";`) + this.addSql(`drop index if exists "IDX_option_product_id_title_unique";`) + this.addSql( + `alter table if exists "product_option" drop column if exists "product_id";` + ) + } + + override async down(): Promise { + this.addSql(`drop table if exists "product_product_option" cascade;`) + + this.addSql( + `alter table if exists "product_option" drop column if exists "is_exclusive";` + ) + + this.addSql( + `alter table if exists "product_option" add column if not exists "product_id" text not 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( + `CREATE INDEX IF NOT EXISTS "IDX_product_option_product_id" ON "product_option" (product_id) WHERE deleted_at IS NULL;` + ) + this.addSql( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_option_product_id_title_unique" ON "product_option" (product_id, title) WHERE deleted_at IS NULL;` + ) + } +} diff --git a/packages/modules/product/src/models/product-option.ts b/packages/modules/product/src/models/product-option.ts index 553209193a840..b178c12389938 100644 --- a/packages/modules/product/src/models/product-option.ts +++ b/packages/modules/product/src/models/product-option.ts @@ -6,8 +6,9 @@ const ProductOption = model .define("ProductOption", { id: model.id({ prefix: "opt" }).primaryKey(), title: model.text().searchable(), + is_exclusive: model.boolean().default(false), metadata: model.json().nullable(), - product: model.belongsTo(() => Product, { + products: model.manyToMany(() => Product, { mappedBy: "options", }), values: model.hasMany(() => ProductOptionValue, { @@ -17,13 +18,5 @@ const ProductOption = model .cascades({ delete: ["values"], }) - .indexes([ - { - name: "IDX_option_product_id_title_unique", - on: ["product_id", "title"], - unique: true, - where: "deleted_at IS NULL", - }, - ]) export default ProductOption diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index a9daced7c0260..f644105585dfd 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -43,8 +43,11 @@ const Product = model mappedBy: "products", pivotTable: "product_tags", }), - options: model.hasMany(() => ProductOption, { - mappedBy: "product", + options: model.manyToMany(() => ProductOption, { + pivotTable: "product_product_option", + mappedBy: "products", + joinColumn: "product_id", + inverseJoinColumn: "product_option_id", }), images: model.hasMany(() => ProductImage, { mappedBy: "product", @@ -60,7 +63,7 @@ const Product = model }), }) .cascades({ - delete: ["variants", "options", "images"], + delete: ["variants", "images"], }) .indexes([ { diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 297b246cf90ea..e01b47084f0a8 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -303,10 +303,12 @@ export default class ProductModuleService const productOptions = await this.productOptionService_.list( { - product_id: [...new Set(data.map((v) => v.product_id!))], + products: { + id: [...new Set(data.map((v) => v.product_id!))], + }, }, { - relations: ["values"], + relations: ["values", "products"], }, sharedContext ) @@ -470,11 +472,13 @@ export default class ProductModuleService const productOptions = await this.productOptionService_.list( { - product_id: Array.from( - new Set(variantsWithProductId.map((v) => v.product_id!)) - ), + products: { + id: Array.from( + new Set(variantsWithProductId.map((v) => v.product_id!)) + ), + }, }, - { relations: ["values"] }, + { relations: ["values", "products"] }, sharedContext ) @@ -801,19 +805,13 @@ export default class ProductModuleService data: ProductTypes.CreateProductOptionDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { - if (data.some((v) => !v.product_id)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Tried to create options without specifying a product_id" - ) - } - const normalizedInput = data.map((opt) => { return { ...opt, values: opt.values?.map((v) => { return typeof v === "string" ? { value: v } : v }), + isExclusive: true, } }) @@ -1673,7 +1671,14 @@ export default class ProductModuleService id: data.map((d) => d.id), }, { - relations: ["options", "options.values", "variants", "images", "tags"], + relations: [ + "options", + "options.values", + "options.products", + "variants", + "images", + "tags", + ], }, sharedContext ) @@ -1953,9 +1958,7 @@ export default class ProductModuleService if (productData.options?.length) { ;(productData as any).options = productData.options?.map((option) => { const dbOption = dbOptions.find( - (o) => - (o.title === option.title || o.id === option.id) && - o.product_id === productData.id + (o) => o.title === option.title || o.id === option.id ) return { title: option.title, @@ -2037,7 +2040,10 @@ export default class ProductModuleService variants.map((v) => ({ ...v, // adding product_id to the variant to make it valid for the assignOptionsToVariants function - ...(options.length ? { product_id: options[0].product_id } : {}), + // get product_id from the first product in the products array of the first option + ...(options.length && options[0].products?.length + ? { product_id: options[0].products[0].id } + : {}), })), options ) @@ -2063,9 +2069,13 @@ export default class ProductModuleService variant.options || {} ).length - const productsOptions = options.filter( - (o) => o.product_id === variant.product_id - ) + const productsOptions = options.filter((o) => { + // products could be a Collection object or array, normalize to array + const productsArray = Array.isArray(o.products) + ? o.products + : (o.products as any)?.toArray?.() ?? [] + return productsArray.some((p) => p.id === variant.product_id) + }) if ( numOfProvidedVariantOptionValues && From f7de05e759b02e29b0fe3af2567bc52c2534dcb1 Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 23 Oct 2025 11:13:34 -0400 Subject: [PATCH 02/36] tests --- .../http/__tests__/product/admin/product.spec.ts | 11 +---------- packages/core/types/src/product/common.ts | 2 +- .../admin/products/[id]/options/[option_id]/route.ts | 2 +- .../src/api/admin/products/[id]/options/route.ts | 2 +- .../product/src/services/product-module-service.ts | 10 +++++++++- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 28e2e4558e014..fbbc15f95aa74 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -1,8 +1,5 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" -import { - adminHeaders, - createAdminUser, -} from "../../../../helpers/create-admin-user" +import { adminHeaders, createAdminUser, } from "../../../../helpers/create-admin-user" import { getProductFixture } from "../../../../helpers/fixtures" jest.setTimeout(50000) @@ -670,7 +667,6 @@ medusaIntegrationTestRunner({ expect.objectContaining({ value: "100" }), ]), id: expect.stringMatching(/^opt_*/), - product_id: expect.stringMatching(/^prod_*/), created_at: expect.any(String), updated_at: expect.any(String), }), @@ -758,7 +754,6 @@ medusaIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ id: expect.stringMatching(/^opt_*/), - product_id: expect.stringMatching(/^prod_*/), created_at: expect.any(String), updated_at: expect.any(String), }), @@ -1517,7 +1512,6 @@ medusaIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ id: expect.stringMatching(/^opt_*/), - product_id: expect.stringMatching(/^prod_*/), title: "size", values: expect.arrayContaining([ expect.objectContaining({ value: "large" }), @@ -1527,7 +1521,6 @@ medusaIntegrationTestRunner({ }), expect.objectContaining({ id: expect.stringMatching(/^opt_*/), - product_id: expect.stringMatching(/^prod_*/), title: "color", values: expect.arrayContaining([ expect.objectContaining({ value: "green" }), @@ -1979,7 +1972,6 @@ medusaIntegrationTestRunner({ expect.objectContaining({ created_at: expect.any(String), id: expect.stringMatching(/^opt_*/), - product_id: baseProduct.id, title: "size", values: expect.arrayContaining([ expect.objectContaining({ value: "large" }), @@ -2625,7 +2617,6 @@ medusaIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ title: "should_add", - product_id: baseProduct.id, values: expect.arrayContaining([ expect.objectContaining({ value: "100" }), ]), diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index 5370d97914ac6..9a602ce2dfa72 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -1210,7 +1210,7 @@ export interface CreateProductOptionDTO { /** * Whether the product option is exclusive or global. */ - isExclusive?: boolean + is_exclusive?: boolean } export interface CreateProductOptionValueDTO { diff --git a/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts b/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts index acfb3cbc24c59..8d53278a9ab80 100644 --- a/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts @@ -19,7 +19,7 @@ export const GET = async ( const optionId = req.params.option_id const productOption = await refetchEntity({ entity: "product_option", - idOrFilter: { id: optionId, product_id: productId }, + idOrFilter: { id: optionId, products: { id: productId } }, scope: req.scope, fields: req.queryConfig.fields, }) diff --git a/packages/medusa/src/api/admin/products/[id]/options/route.ts b/packages/medusa/src/api/admin/products/[id]/options/route.ts index f0ff19de0b5da..4248733f6ea11 100644 --- a/packages/medusa/src/api/admin/products/[id]/options/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/options/route.ts @@ -16,7 +16,7 @@ export const GET = async ( const productId = req.params.id const { data: product_options, metadata } = await refetchEntities({ entity: "product_option", - idOrFilter: { ...req.filterableFields, product_id: productId }, + idOrFilter: { ...req.filterableFields, products: { id: productId } }, scope: req.scope, fields: req.queryConfig.fields, pagination: req.queryConfig.pagination, diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index e01b47084f0a8..1e0db2ca2b136 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -70,6 +70,7 @@ type InjectedDependencies = { productImageProductService: ModulesSdkTypes.IMedusaInternalService productTypeService: ModulesSdkTypes.IMedusaInternalService productOptionService: ModulesSdkTypes.IMedusaInternalService + productProductOptionService: ModulesSdkTypes.IMedusaInternalService productOptionValueService: ModulesSdkTypes.IMedusaInternalService [Modules.EVENT_BUS]?: IEventBusModuleService } @@ -805,13 +806,20 @@ export default class ProductModuleService data: ProductTypes.CreateProductOptionDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { + // TODO - This is just temporary until next PR updates the way we create options and associate them to products + const manager = (sharedContext.transactionManager ?? + sharedContext.manager) as EntityManager + const normalizedInput = data.map((opt) => { return { ...opt, values: opt.values?.map((v) => { return typeof v === "string" ? { value: v } : v }), - isExclusive: true, + is_exclusive: true, // TODO - Next PR will update this when we actually support new global options + ...(opt.product_id + ? { products: [manager.getReference(Product, opt.product_id)] } + : {}), } }) From 2210c08eefcdd256edacbb5d9a9dde856adbc631 Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 23 Oct 2025 11:44:47 -0400 Subject: [PATCH 03/36] define model for pivot table --- .../modules/product/src/models/product-option.ts | 4 +--- .../product/src/models/product-product-option.ts | 15 +++++++++++++++ packages/modules/product/src/models/product.ts | 6 ++---- 3 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 packages/modules/product/src/models/product-product-option.ts diff --git a/packages/modules/product/src/models/product-option.ts b/packages/modules/product/src/models/product-option.ts index b178c12389938..c399b82dd24da 100644 --- a/packages/modules/product/src/models/product-option.ts +++ b/packages/modules/product/src/models/product-option.ts @@ -8,9 +8,7 @@ const ProductOption = model title: model.text().searchable(), is_exclusive: model.boolean().default(false), metadata: model.json().nullable(), - products: model.manyToMany(() => Product, { - mappedBy: "options", - }), + products: model.manyToMany(() => Product), values: model.hasMany(() => ProductOptionValue, { mappedBy: "option", }), diff --git a/packages/modules/product/src/models/product-product-option.ts b/packages/modules/product/src/models/product-product-option.ts new file mode 100644 index 0000000000000..9c62059c7208d --- /dev/null +++ b/packages/modules/product/src/models/product-product-option.ts @@ -0,0 +1,15 @@ +import { model } from "@medusajs/framework/utils" +import Product from "./product" +import ProductOption from "./product-option" + +const ProductProductOption = model.define("ProductProductOption", { + id: model.id({ prefix: "prodopt" }).primaryKey(), + product: model.belongsTo(() => Product, { + mappedBy: "options", + }), + option: model.belongsTo(() => ProductOption, { + mappedBy: "products", + }), +}) + +export default ProductProductOption diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index f644105585dfd..d702c139ea339 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -7,6 +7,7 @@ import ProductOption from "./product-option" import ProductTag from "./product-tag" import ProductType from "./product-type" import ProductVariant from "./product-variant" +import ProductProductOption from "./product-product-option" const Product = model .define("Product", { @@ -44,10 +45,7 @@ const Product = model pivotTable: "product_tags", }), options: model.manyToMany(() => ProductOption, { - pivotTable: "product_product_option", - mappedBy: "products", - joinColumn: "product_id", - inverseJoinColumn: "product_option_id", + pivotEntity: () => ProductProductOption, }), images: model.hasMany(() => ProductImage, { mappedBy: "product", From cb3dda52853c3108c95ca8eae2c7c35f7ade20ce Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 23 Oct 2025 12:02:56 -0400 Subject: [PATCH 04/36] test commit will revert --- .../migrations/.snapshot-medusa-product.json | 16 ++++++------- .../src/migrations/Migration20251023160102.ts | 23 +++++++++++++++++++ .../src/migrations/Migration20251023160239.ts | 23 +++++++++++++++++++ .../product/src/models/product-option.ts | 6 ++++- .../modules/product/src/models/product.ts | 1 + 5 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 packages/modules/product/src/migrations/Migration20251023160102.ts create mode 100644 packages/modules/product/src/migrations/Migration20251023160239.ts diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 63be1603bef3a..59abd94f6798e 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -1318,11 +1318,11 @@ "mappedType": "text" } }, - "name": "product_product_option", + "name": "product_options", "schema": "public", "indexes": [ { - "keyName": "product_product_option_pkey", + "keyName": "product_options_pkey", "columnNames": [ "product_id", "product_option_id" @@ -1335,12 +1335,12 @@ ], "checks": [], "foreignKeys": { - "product_product_option_product_id_foreign": { - "constraintName": "product_product_option_product_id_foreign", + "product_options_product_id_foreign": { + "constraintName": "product_options_product_id_foreign", "columnNames": [ "product_id" ], - "localTableName": "public.product_product_option", + "localTableName": "public.product_options", "referencedColumnNames": [ "id" ], @@ -1349,12 +1349,12 @@ "deleteRule": "cascade", "updateRule": "cascade" }, - "product_product_option_product_option_id_foreign": { - "constraintName": "product_product_option_product_option_id_foreign", + "product_options_product_option_id_foreign": { + "constraintName": "product_options_product_option_id_foreign", "columnNames": [ "product_option_id" ], - "localTableName": "public.product_product_option", + "localTableName": "public.product_options", "referencedColumnNames": [ "id" ], diff --git a/packages/modules/product/src/migrations/Migration20251023160102.ts b/packages/modules/product/src/migrations/Migration20251023160102.ts new file mode 100644 index 0000000000000..25950017a7763 --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20251023160102.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251023160102 extends Migration { + + override async up(): Promise { + this.addSql(`create table if not exists "product_options" ("product_id" text not null, "product_option_id" text not null, constraint "product_options_pkey" primary key ("product_id", "product_option_id"));`); + + this.addSql(`alter table if exists "product_options" add constraint "product_options_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table if exists "product_options" add constraint "product_options_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;`); + + this.addSql(`drop table if exists "product_product_option" cascade;`); + } + + override async down(): Promise { + this.addSql(`create table if not exists "product_product_option" ("product_id" text not null, "product_option_id" text not null, constraint "product_product_option_pkey" primary key ("product_id", "product_option_id"));`); + + this.addSql(`alter table if exists "product_product_option" add constraint "product_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_product_option" add constraint "product_product_option_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;`); + + this.addSql(`drop table if exists "product_options" cascade;`); + } + +} diff --git a/packages/modules/product/src/migrations/Migration20251023160239.ts b/packages/modules/product/src/migrations/Migration20251023160239.ts new file mode 100644 index 0000000000000..97c6f39f7f1b4 --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20251023160239.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251023160239 extends Migration { + + override async up(): Promise { + this.addSql(`create table if not exists "product_options" ("product_id" text not null, "product_option_id" text not null, constraint "product_options_pkey" primary key ("product_id", "product_option_id"));`); + + this.addSql(`alter table if exists "product_options" add constraint "product_options_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table if exists "product_options" add constraint "product_options_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;`); + + this.addSql(`drop table if exists "product_product_option" cascade;`); + } + + override async down(): Promise { + this.addSql(`create table if not exists "product_product_option" ("product_id" text not null, "product_option_id" text not null, constraint "product_product_option_pkey" primary key ("product_id", "product_option_id"));`); + + this.addSql(`alter table if exists "product_product_option" add constraint "product_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_product_option" add constraint "product_product_option_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;`); + + this.addSql(`drop table if exists "product_options" cascade;`); + } + +} diff --git a/packages/modules/product/src/models/product-option.ts b/packages/modules/product/src/models/product-option.ts index c399b82dd24da..a2f082e865a76 100644 --- a/packages/modules/product/src/models/product-option.ts +++ b/packages/modules/product/src/models/product-option.ts @@ -1,6 +1,7 @@ import { model } from "@medusajs/framework/utils" import { Product } from "./index" import ProductOptionValue from "./product-option-value" +import ProductProductOption from "./product-product-option" const ProductOption = model .define("ProductOption", { @@ -8,7 +9,10 @@ const ProductOption = model title: model.text().searchable(), is_exclusive: model.boolean().default(false), metadata: model.json().nullable(), - products: model.manyToMany(() => Product), + products: model.manyToMany(() => Product, { + mappedBy: "options", + pivotEntity: () => ProductProductOption, + }), values: model.hasMany(() => ProductOptionValue, { mappedBy: "option", }), diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index d702c139ea339..b43c40ad40add 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -45,6 +45,7 @@ const Product = model pivotTable: "product_tags", }), options: model.manyToMany(() => ProductOption, { + mappedBy: "products", pivotEntity: () => ProductProductOption, }), images: model.hasMany(() => ProductImage, { From 4a62432b5cd1d5b38e6d0c5807310e7eea10fe1d Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 23 Oct 2025 12:44:13 -0400 Subject: [PATCH 05/36] Revert "test commit will revert" This reverts commit cb3dda52853c3108c95ca8eae2c7c35f7ade20ce. --- .../migrations/.snapshot-medusa-product.json | 16 ++++++------- .../src/migrations/Migration20251023160102.ts | 23 ------------------- .../src/migrations/Migration20251023160239.ts | 23 ------------------- .../product/src/models/product-option.ts | 6 +---- .../modules/product/src/models/product.ts | 1 - 5 files changed, 9 insertions(+), 60 deletions(-) delete mode 100644 packages/modules/product/src/migrations/Migration20251023160102.ts delete mode 100644 packages/modules/product/src/migrations/Migration20251023160239.ts diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 59abd94f6798e..63be1603bef3a 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -1318,11 +1318,11 @@ "mappedType": "text" } }, - "name": "product_options", + "name": "product_product_option", "schema": "public", "indexes": [ { - "keyName": "product_options_pkey", + "keyName": "product_product_option_pkey", "columnNames": [ "product_id", "product_option_id" @@ -1335,12 +1335,12 @@ ], "checks": [], "foreignKeys": { - "product_options_product_id_foreign": { - "constraintName": "product_options_product_id_foreign", + "product_product_option_product_id_foreign": { + "constraintName": "product_product_option_product_id_foreign", "columnNames": [ "product_id" ], - "localTableName": "public.product_options", + "localTableName": "public.product_product_option", "referencedColumnNames": [ "id" ], @@ -1349,12 +1349,12 @@ "deleteRule": "cascade", "updateRule": "cascade" }, - "product_options_product_option_id_foreign": { - "constraintName": "product_options_product_option_id_foreign", + "product_product_option_product_option_id_foreign": { + "constraintName": "product_product_option_product_option_id_foreign", "columnNames": [ "product_option_id" ], - "localTableName": "public.product_options", + "localTableName": "public.product_product_option", "referencedColumnNames": [ "id" ], diff --git a/packages/modules/product/src/migrations/Migration20251023160102.ts b/packages/modules/product/src/migrations/Migration20251023160102.ts deleted file mode 100644 index 25950017a7763..0000000000000 --- a/packages/modules/product/src/migrations/Migration20251023160102.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Migration } from '@mikro-orm/migrations'; - -export class Migration20251023160102 extends Migration { - - override async up(): Promise { - this.addSql(`create table if not exists "product_options" ("product_id" text not null, "product_option_id" text not null, constraint "product_options_pkey" primary key ("product_id", "product_option_id"));`); - - this.addSql(`alter table if exists "product_options" add constraint "product_options_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;`); - this.addSql(`alter table if exists "product_options" add constraint "product_options_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;`); - - this.addSql(`drop table if exists "product_product_option" cascade;`); - } - - override async down(): Promise { - this.addSql(`create table if not exists "product_product_option" ("product_id" text not null, "product_option_id" text not null, constraint "product_product_option_pkey" primary key ("product_id", "product_option_id"));`); - - this.addSql(`alter table if exists "product_product_option" add constraint "product_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_product_option" add constraint "product_product_option_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;`); - - this.addSql(`drop table if exists "product_options" cascade;`); - } - -} diff --git a/packages/modules/product/src/migrations/Migration20251023160239.ts b/packages/modules/product/src/migrations/Migration20251023160239.ts deleted file mode 100644 index 97c6f39f7f1b4..0000000000000 --- a/packages/modules/product/src/migrations/Migration20251023160239.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Migration } from '@mikro-orm/migrations'; - -export class Migration20251023160239 extends Migration { - - override async up(): Promise { - this.addSql(`create table if not exists "product_options" ("product_id" text not null, "product_option_id" text not null, constraint "product_options_pkey" primary key ("product_id", "product_option_id"));`); - - this.addSql(`alter table if exists "product_options" add constraint "product_options_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;`); - this.addSql(`alter table if exists "product_options" add constraint "product_options_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;`); - - this.addSql(`drop table if exists "product_product_option" cascade;`); - } - - override async down(): Promise { - this.addSql(`create table if not exists "product_product_option" ("product_id" text not null, "product_option_id" text not null, constraint "product_product_option_pkey" primary key ("product_id", "product_option_id"));`); - - this.addSql(`alter table if exists "product_product_option" add constraint "product_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_product_option" add constraint "product_product_option_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;`); - - this.addSql(`drop table if exists "product_options" cascade;`); - } - -} diff --git a/packages/modules/product/src/models/product-option.ts b/packages/modules/product/src/models/product-option.ts index a2f082e865a76..c399b82dd24da 100644 --- a/packages/modules/product/src/models/product-option.ts +++ b/packages/modules/product/src/models/product-option.ts @@ -1,7 +1,6 @@ import { model } from "@medusajs/framework/utils" import { Product } from "./index" import ProductOptionValue from "./product-option-value" -import ProductProductOption from "./product-product-option" const ProductOption = model .define("ProductOption", { @@ -9,10 +8,7 @@ const ProductOption = model title: model.text().searchable(), is_exclusive: model.boolean().default(false), metadata: model.json().nullable(), - products: model.manyToMany(() => Product, { - mappedBy: "options", - pivotEntity: () => ProductProductOption, - }), + products: model.manyToMany(() => Product), values: model.hasMany(() => ProductOptionValue, { mappedBy: "option", }), diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index b43c40ad40add..d702c139ea339 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -45,7 +45,6 @@ const Product = model pivotTable: "product_tags", }), options: model.manyToMany(() => ProductOption, { - mappedBy: "products", pivotEntity: () => ProductProductOption, }), images: model.hasMany(() => ProductImage, { From 8fc85cab8f05f17f29ad862b088051f68f2db530 Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 23 Oct 2025 12:47:05 -0400 Subject: [PATCH 06/36] model for pivot table --- .../migrations/.snapshot-medusa-product.json | 176 +++++++++++------- packages/modules/product/src/models/index.ts | 1 + 2 files changed, 108 insertions(+), 69 deletions(-) diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 63be1603bef3a..2b7d69d629fdb 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -1060,6 +1060,113 @@ }, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "product_id": { + "name": "product_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "option_id": { + "name": "option_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "product_product_option", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_product_product_option_product_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_product_id\" ON \"product_product_option\" (product_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_product_product_option_option_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_option_id\" ON \"product_product_option\" (option_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_product_product_option_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_deleted_at\" ON \"product_product_option\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "product_product_option_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, { "columns": { "id": { @@ -1297,75 +1404,6 @@ }, "nativeEnums": {} }, - { - "columns": { - "product_id": { - "name": "product_id", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "product_option_id": { - "name": "product_option_id", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - } - }, - "name": "product_product_option", - "schema": "public", - "indexes": [ - { - "keyName": "product_product_option_pkey", - "columnNames": [ - "product_id", - "product_option_id" - ], - "composite": true, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "product_product_option_product_id_foreign": { - "constraintName": "product_product_option_product_id_foreign", - "columnNames": [ - "product_id" - ], - "localTableName": "public.product_product_option", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.product", - "createForeignKeyConstraint": true, - "deleteRule": "cascade", - "updateRule": "cascade" - }, - "product_product_option_product_option_id_foreign": { - "constraintName": "product_product_option_product_option_id_foreign", - "columnNames": [ - "product_option_id" - ], - "localTableName": "public.product_product_option", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.product_option", - "createForeignKeyConstraint": true, - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, - "nativeEnums": {} - }, { "columns": { "product_id": { diff --git a/packages/modules/product/src/models/index.ts b/packages/modules/product/src/models/index.ts index b91e65753de18..bbe92163b3583 100644 --- a/packages/modules/product/src/models/index.ts +++ b/packages/modules/product/src/models/index.ts @@ -3,6 +3,7 @@ export { default as ProductCategory } from "./product-category" export { default as ProductCollection } from "./product-collection" export { default as ProductImage } from "./product-image" export { default as ProductOption } from "./product-option" +export { default as ProductProductOption } from "./product-product-option" export { default as ProductOptionValue } from "./product-option-value" export { default as ProductTag } from "./product-tag" export { default as ProductType } from "./product-type" From a7cbbfeccf93ea462f8253b1ebae71596e816476 Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 23 Oct 2025 14:26:17 -0400 Subject: [PATCH 07/36] more --- .../__tests__/product/admin/product.spec.ts | 7 +- .../migrations/.snapshot-medusa-product.json | 8 +- .../src/migrations/Migration20251022153442.ts | 139 ++++++++++++------ .../src/models/product-product-option.ts | 2 +- .../src/services/product-module-service.ts | 39 +++-- 5 files changed, 138 insertions(+), 57 deletions(-) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index fbbc15f95aa74..f16878b3f4767 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -1,5 +1,8 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" -import { adminHeaders, createAdminUser, } from "../../../../helpers/create-admin-user" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" import { getProductFixture } from "../../../../helpers/fixtures" jest.setTimeout(50000) @@ -2594,7 +2597,7 @@ medusaIntegrationTestRunner({ ) }) - it("add option", async () => { + it.only("add option", async () => { const payload = { title: "should_add", values: ["100"], diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 2b7d69d629fdb..1fc3aa7357840 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -1080,8 +1080,8 @@ "nullable": false, "mappedType": "text" }, - "option_id": { - "name": "option_id", + "product_option_id": { + "name": "product_option_id", "type": "text", "unsigned": false, "autoincrement": false, @@ -1135,13 +1135,13 @@ "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_product_id\" ON \"product_product_option\" (product_id) WHERE deleted_at IS NULL" }, { - "keyName": "IDX_product_product_option_option_id", + "keyName": "IDX_product_product_option_product_option_id", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_option_id\" ON \"product_product_option\" (option_id) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_product_option_id\" ON \"product_product_option\" (product_option_id) WHERE deleted_at IS NULL" }, { "keyName": "IDX_product_product_option_deleted_at", diff --git a/packages/modules/product/src/migrations/Migration20251022153442.ts b/packages/modules/product/src/migrations/Migration20251022153442.ts index 59bc924c42ac2..a12c48d5f99b7 100644 --- a/packages/modules/product/src/migrations/Migration20251022153442.ts +++ b/packages/modules/product/src/migrations/Migration20251022153442.ts @@ -1,39 +1,68 @@ import { Migration } from "@mikro-orm/migrations" -export class Migration20251022153442 extends Migration { +export class Migration20251023171945 extends Migration { override async up(): Promise { - this.addSql( - `create table if not exists "product_product_option" ("product_id" text not null, "product_option_id" text not null, constraint "product_product_option_pkey" primary key ("product_id", "product_option_id"));` - ) + this.addSql(` + create table if not exists "product_product_option" ( + "id" text not null, + "product_id" text not null, + "product_option_id" text not null, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now(), + "deleted_at" timestamptz null, + constraint "product_product_option_pkey" primary key ("id") + ); + `) - this.addSql( - `alter table if exists "product_product_option" add constraint "product_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_product_option" add constraint "product_product_option_product_option_id_foreign" foreign key ("product_option_id") references "product_option" ("id") on update cascade on delete cascade;` - ) + this.addSql(` + alter table if exists "product_product_option" + add constraint "product_product_option_product_id_foreign" + foreign key ("product_id") references "product" ("id") + on update cascade on delete cascade; + `) - // Add is_exclusive column before migrating data - this.addSql( - `alter table if exists "product_option" add column if not exists "is_exclusive" boolean not null default false;` - ) + this.addSql(` + alter table if exists "product_product_option" + add constraint "product_product_option_product_option_id_foreign" + foreign key ("product_option_id") references "product_option" ("id") + on update cascade on delete cascade; + `) - // Migrate existing product_id relationships to the join table - this.addSql( - `insert into "product_product_option" ("product_id", "product_option_id") - select "product_id", "id" from "product_option" where "product_id" is not null;` - ) + this.addSql(` + CREATE INDEX IF NOT EXISTS "IDX_product_product_option_product_id" + ON "product_product_option" (product_id) WHERE deleted_at IS NULL; + `) - // Set is_exclusive to true for all existing options - this.addSql( - `update "product_option" set "is_exclusive" = true where "id" is not null;` - ) + this.addSql(` + CREATE INDEX IF NOT EXISTS "IDX_product_product_option_product_option_id" + ON "product_product_option" (product_option_id) WHERE deleted_at IS NULL; + `) + + this.addSql(` + CREATE INDEX IF NOT EXISTS "IDX_product_product_option_deleted_at" + ON "product_product_option" (deleted_at) WHERE deleted_at IS NULL; + `) + + this.addSql(` + alter table if exists "product_option" + add column if not exists "is_exclusive" boolean not null default false; + `) + + this.addSql(` + insert into "product_product_option" ("id", "product_id", "product_option_id") + select gen_random_uuid(), "product_id", "id" + from "product_option" + where "product_id" is not null; + `) + + this.addSql(` + update "product_option" set "is_exclusive" = true + where "product_id" is not null; + `) - // Now drop the old product_id column and constraints this.addSql( `alter table if exists "product_option" drop constraint if exists "product_option_product_id_foreign";` ) - this.addSql(`drop index if exists "IDX_product_option_product_id";`) this.addSql(`drop index if exists "IDX_option_product_id_title_unique";`) this.addSql( @@ -42,23 +71,51 @@ export class Migration20251022153442 extends Migration { } override async down(): Promise { - this.addSql(`drop table if exists "product_product_option" cascade;`) + // Recreate product_id column before removing the pivot + this.addSql(` + alter table if exists "product_option" + add column if not exists "product_id" text; + `) - this.addSql( - `alter table if exists "product_option" drop column if exists "is_exclusive";` - ) + // Migrate data back from join table + this.addSql(` + update "product_option" po + set "product_id" = ppo."product_id" + from "product_product_option" ppo + where po."id" = ppo."product_option_id" + and ppo."deleted_at" is null; + `) - this.addSql( - `alter table if exists "product_option" add column if not exists "product_id" text not 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( - `CREATE INDEX IF NOT EXISTS "IDX_product_option_product_id" ON "product_option" (product_id) WHERE deleted_at IS NULL;` - ) - this.addSql( - `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_option_product_id_title_unique" ON "product_option" (product_id, title) WHERE deleted_at IS NULL;` - ) + // Make product_id NOT NULL + this.addSql(` + alter table if exists "product_option" + alter column "product_id" set not null; + `) + + // Re-add foreign key and indexes + 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(` + CREATE INDEX IF NOT EXISTS "IDX_product_option_product_id" + ON "product_option" (product_id) WHERE deleted_at IS NULL; + `) + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_option_product_id_title_unique" + ON "product_option" (product_id, title) WHERE deleted_at IS NULL; + `) + + // Drop the join table + this.addSql(`drop table if exists "product_product_option" cascade;`) + + // Drop is_exclusive column + this.addSql(` + alter table if exists "product_option" drop column if exists "is_exclusive"; + `) } } diff --git a/packages/modules/product/src/models/product-product-option.ts b/packages/modules/product/src/models/product-product-option.ts index 9c62059c7208d..45013b5fbffda 100644 --- a/packages/modules/product/src/models/product-product-option.ts +++ b/packages/modules/product/src/models/product-product-option.ts @@ -7,7 +7,7 @@ const ProductProductOption = model.define("ProductProductOption", { product: model.belongsTo(() => Product, { mappedBy: "options", }), - option: model.belongsTo(() => ProductOption, { + product_option: model.belongsTo(() => ProductOption, { mappedBy: "products", }), }) diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 1e0db2ca2b136..94698077fee6c 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -17,6 +17,7 @@ import { ProductImage, ProductOption, ProductOptionValue, + ProductProductOption, ProductTag, ProductType, ProductVariant, @@ -44,7 +45,6 @@ import { removeUndefined, toHandle, } from "@medusajs/framework/utils" -import { EntityManager } from "@mikro-orm/core" import { ProductRepository } from "../repositories" import { UpdateCategoryInput, @@ -57,6 +57,7 @@ import { } from "../types" import { joinerConfig } from "./../joiner-config" import { eventBuilders } from "../utils/events" +import { EntityManager } from "@mikro-orm/core" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -144,6 +145,9 @@ export default class ProductModuleService protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > + protected readonly productProductOptionService_: ModulesSdkTypes.IMedusaInternalService< + InferEntityType + > protected readonly eventBusModuleService_?: IEventBusModuleService constructor( @@ -158,6 +162,7 @@ export default class ProductModuleService productImageService, productTypeService, productOptionService, + productProductOptionService, productOptionValueService, [Modules.EVENT_BUS]: eventBusModuleService, }: InjectedDependencies, @@ -177,6 +182,7 @@ export default class ProductModuleService this.productImageService_ = productImageService this.productTypeService_ = productTypeService this.productOptionService_ = productOptionService + this.productProductOptionService_ = productProductOptionService this.productOptionValueService_ = productOptionValueService this.eventBusModuleService_ = eventBusModuleService } @@ -806,10 +812,6 @@ export default class ProductModuleService data: ProductTypes.CreateProductOptionDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { - // TODO - This is just temporary until next PR updates the way we create options and associate them to products - const manager = (sharedContext.transactionManager ?? - sharedContext.manager) as EntityManager - const normalizedInput = data.map((opt) => { return { ...opt, @@ -817,16 +819,35 @@ export default class ProductModuleService return typeof v === "string" ? { value: v } : v }), is_exclusive: true, // TODO - Next PR will update this when we actually support new global options - ...(opt.product_id - ? { products: [manager.getReference(Product, opt.product_id)] } - : {}), } }) - return await this.productOptionService_.create( + const createdOptions = await this.productOptionService_.create( normalizedInput, sharedContext ) + + // Create pivot table entries for options with product_id + const pivotEntries = data + .map((opt, index) => { + if (opt.product_id) { + return { + product: opt.product_id, + product_option: createdOptions[index].id, + } + } + return null + }) + .filter(Boolean) + + if (pivotEntries.length > 0) { + await this.productProductOptionService_.create( + pivotEntries, + sharedContext + ) + } + + return createdOptions } async upsertProductOptions( From af7b374bf557595115ef11454d91ec6a6078c10f Mon Sep 17 00:00:00 2001 From: willbouch Date: Fri, 24 Oct 2025 09:20:28 -0400 Subject: [PATCH 08/36] new product options endpoint --- integration-tests/.env.test | 2 +- .../product-option/product-option.spec.ts | 168 ++++++++++++++++++ .../__tests__/product/admin/product.spec.ts | 1 + .../product/steps/update-product-options.ts | 6 +- .../workflows/create-product-options.ts | 5 +- .../types/src/http/product/admin/entitites.ts | 4 +- .../types/src/http/product/admin/payloads.ts | 8 + .../types/src/http/product/admin/queries.ts | 10 +- .../core/types/src/http/product/common.ts | 39 +++- packages/core/types/src/product/common.ts | 24 ++- .../api/admin/product-options/[id]/route.ts | 82 +++++++++ .../api/admin/product-options/middlewares.ts | 62 +++++++ .../api/admin/product-options/query-config.ts | 20 +++ .../src/api/admin/product-options/route.ts | 49 +++++ .../api/admin/product-options/validators.ts | 50 ++++++ .../[id]/options/[option_id]/route.ts | 166 ++++++++--------- .../api/admin/products/[id]/options/route.ts | 122 ++++++------- packages/medusa/src/api/middlewares.ts | 2 + .../src/services/product-module-service.ts | 28 +-- 19 files changed, 648 insertions(+), 200 deletions(-) create mode 100644 integration-tests/http/__tests__/product-option/product-option.spec.ts create mode 100644 packages/medusa/src/api/admin/product-options/[id]/route.ts create mode 100644 packages/medusa/src/api/admin/product-options/middlewares.ts create mode 100644 packages/medusa/src/api/admin/product-options/query-config.ts create mode 100644 packages/medusa/src/api/admin/product-options/route.ts create mode 100644 packages/medusa/src/api/admin/product-options/validators.ts diff --git a/integration-tests/.env.test b/integration-tests/.env.test index 79690313e43c7..ecf75cd5355ff 100644 --- a/integration-tests/.env.test +++ b/integration-tests/.env.test @@ -2,4 +2,4 @@ DB_HOST=localhost DB_USERNAME=postgres DB_PASSWORD='' -LOG_LEVEL=error \ No newline at end of file +LOG_LEVEL=error diff --git a/integration-tests/http/__tests__/product-option/product-option.spec.ts b/integration-tests/http/__tests__/product-option/product-option.spec.ts new file mode 100644 index 0000000000000..38224a7f45b1a --- /dev/null +++ b/integration-tests/http/__tests__/product-option/product-option.spec.ts @@ -0,0 +1,168 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../helpers/create-admin-user" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + env: {}, + testSuite: ({ dbConnection, getContainer, api }) => { + let option1 + let option2 + + beforeEach(async () => { + const container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + + option1 = ( + await api.post( + "/admin/product-options", + { + title: "option1", + values: ["A", "B", "C"], + }, + adminHeaders + ) + ).data.product_option + + option2 = ( + await api.post( + "/admin/product-options", + { + title: "option2", + values: ["D", "E"], + is_exclusive: true, + }, + adminHeaders + ) + ).data.product_option + }) + + describe("GET /admin/product-options", () => { + it("returns a list of product options", async () => { + const res = await api.get("/admin/product-options", adminHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "option1", + is_exclusive: false, + values: expect.arrayContaining([ + expect.objectContaining({ value: "A" }), + expect.objectContaining({ value: "B" }), + expect.objectContaining({ value: "C" }), + ]), + }), + expect.objectContaining({ + title: "option2", + is_exclusive: true, + values: expect.arrayContaining([ + expect.objectContaining({ value: "D" }), + expect.objectContaining({ value: "E" }), + ]), + }), + ]) + ) + }) + + it("returns a list of product options matching free text search param", async () => { + const res = await api.get("/admin/product-options?q=1", adminHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_options.length).toEqual(1) + expect(res.data.product_options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "option1" }), + ]) + ) + }) + + it("returns a list of exclusive product options", async () => { + const res = await api.get( + "/admin/product-options?is_exclusive=false", + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.product_options.length).toEqual(1) + expect(res.data.product_options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "option1" }), + ]) + ) + }) + }) + + describe("GET /admin/product-options/[id]", () => { + it("returns a product option", async () => { + const res = await api.get( + `/admin/product-options/${option1.id}`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.product_option).toEqual( + expect.objectContaining({ + title: "option1", + is_exclusive: false, + values: expect.arrayContaining([ + expect.objectContaining({ value: "A" }), + expect.objectContaining({ value: "B" }), + expect.objectContaining({ value: "C" }), + ]), + }), + ) + }) + }) + + describe("POST /admin/product-options/[id]", () => { + it("updates a product option", async () => { + const option = ( + await api.post( + `/admin/product-options/${option2.id}`, + { + is_exclusive: false, + }, + adminHeaders + ) + ).data.product_option + + expect(option).toEqual( + expect.objectContaining({ + title: "option2", + is_exclusive: false, + values: expect.arrayContaining([ + expect.objectContaining({ value: "D" }), + expect.objectContaining({ value: "E" }), + ]), + }) + ) + + const res = await api.get( + "/admin/product-options?is_exclusive=true", + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.product_options.length).toEqual(0) + }) + }) + + describe("DELETE /admin/product-options/[id]", () => { + it("deletes a product option", async () => { + await api.delete( + `/admin/product-options/${option2.id}`, + adminHeaders + ) + + const res = await api.get("/admin/product-options", adminHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_options.length).toEqual(1) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index f16878b3f4767..58274feea2073 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -8,6 +8,7 @@ import { getProductFixture } from "../../../../helpers/fixtures" jest.setTimeout(50000) medusaIntegrationTestRunner({ + debug: true, testSuite: ({ dbConnection, getContainer, api }) => { let baseProduct let proposedProduct diff --git a/packages/core/core-flows/src/product/steps/update-product-options.ts b/packages/core/core-flows/src/product/steps/update-product-options.ts index 31b6cc60d0cad..4fe1caf68540c 100644 --- a/packages/core/core-flows/src/product/steps/update-product-options.ts +++ b/packages/core/core-flows/src/product/steps/update-product-options.ts @@ -3,10 +3,10 @@ import type { ProductTypes, } from "@medusajs/framework/types" import { - Modules, getSelectsAndRelationsFromObjectArray, + Modules, } from "@medusajs/framework/utils" -import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" /** * The data to identify and update the product options. @@ -67,8 +67,6 @@ export const updateProductOptionsStep = createStep( prevData.map((o) => ({ ...o, values: o.values?.map((v) => v.value), - product: undefined, - product_id: o.product_id ?? undefined, })) ) } diff --git a/packages/core/core-flows/src/product/workflows/create-product-options.ts b/packages/core/core-flows/src/product/workflows/create-product-options.ts index 8bf1a28e93953..958e46f55b949 100644 --- a/packages/core/core-flows/src/product/workflows/create-product-options.ts +++ b/packages/core/core-flows/src/product/workflows/create-product-options.ts @@ -1,11 +1,11 @@ import type { AdditionalData, ProductTypes } from "@medusajs/framework/types" import { ProductOptionWorkflowEvents } from "@medusajs/framework/utils" import { - WorkflowData, - WorkflowResponse, createHook, createWorkflow, transform, + WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { emitEventStep } from "../../common/steps/emit-event" import { createProductOptionsStep } from "../steps" @@ -41,6 +41,7 @@ export const createProductOptionsWorkflowId = "create-product-options" * { * title: "Color", * values: ["Red", "Blue", "Green"] + * is_exclusive: true * } * ], * additional_data: { diff --git a/packages/core/types/src/http/product/admin/entitites.ts b/packages/core/types/src/http/product/admin/entitites.ts index a7c4c5e8a30cf..aef5c5bf7bc40 100644 --- a/packages/core/types/src/http/product/admin/entitites.ts +++ b/packages/core/types/src/http/product/admin/entitites.ts @@ -62,9 +62,9 @@ export interface AdminProductVariant extends BaseProductVariant { } export interface AdminProductOption extends BaseProductOption { /** - * The associated product's details. + * The associated products' details. */ - product?: AdminProduct | null + products?: AdminProduct[] | null /** * The option's values. */ diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index 40d94e38a8a4e..c9243752901fa 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -515,6 +515,10 @@ export interface AdminCreateProductOption { * The option's values. */ values: string[] + /** + * Whether the option is exclusive or global. + */ + is_exclusive?: boolean } export interface AdminUpdateProductOption { @@ -526,6 +530,10 @@ export interface AdminUpdateProductOption { * The option's values. */ values?: string[] + /** + * Whether the option is exclusive or global. + */ + is_exclusive?: boolean } interface AdminCreateProductVariantInventoryItem { diff --git a/packages/core/types/src/http/product/admin/queries.ts b/packages/core/types/src/http/product/admin/queries.ts index ab7520ff99692..7ef7d2cbae207 100644 --- a/packages/core/types/src/http/product/admin/queries.ts +++ b/packages/core/types/src/http/product/admin/queries.ts @@ -1,6 +1,10 @@ import { BaseFilterable, OperatorMap } from "../../../dal" import { FindParams } from "../../common" -import { BaseProductListParams, BaseProductOptionParams } from "../common" +import { + BaseProductListParams, + BaseProductOptionListParams, + BaseProductOptionParams, +} from "../common" export interface AdminProductOptionParams extends Omit {} @@ -49,6 +53,7 @@ export interface AdminProductVariantParams */ deleted_at?: OperatorMap } + export interface AdminProductListParams extends Omit { /** @@ -60,3 +65,6 @@ export interface AdminProductListParams */ variants?: Omit } + +export interface AdminProductOptionListParams + extends Omit {} diff --git a/packages/core/types/src/http/product/common.ts b/packages/core/types/src/http/product/common.ts index dc2837fb378f1..8955f97ac0416 100644 --- a/packages/core/types/src/http/product/common.ts +++ b/packages/core/types/src/http/product/common.ts @@ -257,13 +257,13 @@ export interface BaseProductOption { */ title: string /** - * The product that the option belongs to. + * Whether the option is exclusive or global. */ - product?: BaseProduct | null + is_exclusive: boolean /** - * The ID of the product that the option belongs to. + * The products that the option is associated to. */ - product_id?: string | null + products?: BaseProduct[] | null /** * The option's values. */ @@ -417,13 +417,42 @@ export interface BaseProductListParams deleted_at?: OperatorMap } +export interface BaseProductOptionListParams + extends FindParams, + BaseFilterable { + /** + * A query or keywords to search the searchable fields by. + */ + q?: string + /** + * Filter by the option's title(s). + */ + title?: string | string[] + /** + * Filter by whether the option is exclusive or global. + */ + is_exclusive?: boolean + /** + * Apply filers on the product's creation date. + */ + created_at?: OperatorMap + /** + * Apply filers on the product's update date. + */ + updated_at?: OperatorMap + /** + * Apply filers on the product's deletion date. + */ + deleted_at?: OperatorMap +} + export interface BaseProductOptionParams extends FindParams, BaseFilterable { q?: string id?: string | string[] title?: string | string[] - product_id?: string | string[] + is_exclusive?: boolean } export interface BaseProductVariantParams diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index 9a602ce2dfa72..9abdc9345059b 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -543,15 +543,15 @@ export interface ProductOptionDTO { */ title: string /** - * The associated product. - * - * @expandable + * Whether the product option is exclusive or global. */ - product?: ProductDTO | null + is_exclusive: boolean /** - * The associated product id. + * The associated products. + * + * @expandable */ - product_id?: string | null + products?: ProductDTO[] | null /** * The associated product option values. * @@ -834,9 +834,9 @@ export interface FilterableProductOptionProps */ title?: string | string[] /** - * Filter the product options by their associated products' IDs. + * Filter the product options by exclusivity. */ - product_id?: string | string[] + is_exclusive?: boolean } /** @@ -1203,10 +1203,6 @@ export interface CreateProductOptionDTO { * The product option values. */ values: string[] - /** - * The ID of the associated product. - */ - product_id?: string /** * Whether the product option is exclusive or global. */ @@ -1248,9 +1244,9 @@ export interface UpdateProductOptionDTO { */ values?: string[] /** - * The ID of the associated product. + * Whether the product option is exclusive or global. */ - product_id?: string + is_exclusive?: boolean } export interface UpdateProductOptionValueDTO { diff --git a/packages/medusa/src/api/admin/product-options/[id]/route.ts b/packages/medusa/src/api/admin/product-options/[id]/route.ts new file mode 100644 index 0000000000000..555ebc67fb85b --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/[id]/route.ts @@ -0,0 +1,82 @@ +import { + deleteProductOptionsWorkflow, + updateProductOptionsWorkflow, +} from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, + refetchEntity, +} from "@medusajs/framework/http" + +import { + AdminGetProductOptionParamsType, + AdminUpdateProductOptionType, +} from "../validators" +import { HttpTypes } from "@medusajs/framework/types" +import { MedusaError } from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const product_option = await refetchEntity({ + entity: "product_option", + idOrFilter: req.params.id, + scope: req.scope, + fields: req.queryConfig.fields, + }) + + res.status(200).json({ product_option }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const existingProductOption = await refetchEntity({ + entity: "product_option", + idOrFilter: req.params.id, + scope: req.scope, + fields: ["id"], + }) + + if (!existingProductOption) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product option with id "${req.params.id}" not found` + ) + } + + const { result } = await updateProductOptionsWorkflow(req.scope).run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody, + }, + }) + + const product_option = await refetchEntity({ + entity: "product_option", + idOrFilter: result[0].id, + scope: req.scope, + fields: req.queryConfig.fields, + }) + + res.status(200).json({ product_option }) +} + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + + await deleteProductOptionsWorkflow(req.scope).run({ + input: { ids: [id] }, + }) + + res.status(200).json({ + id, + object: "product_option", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/admin/product-options/middlewares.ts b/packages/medusa/src/api/admin/product-options/middlewares.ts new file mode 100644 index 0000000000000..71b46053f3453 --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/middlewares.ts @@ -0,0 +1,62 @@ +import * as QueryConfig from "./query-config" +import { MiddlewareRoute } from "@medusajs/framework/http" +import { + validateAndTransformBody, + validateAndTransformQuery, +} from "@medusajs/framework" +import { + AdminCreateProductOption, + AdminGetProductOptionParams, + AdminGetProductOptionsParams, + AdminUpdateProductOption, +} from "./validators" + +export const adminProductOptionRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/product-options", + middlewares: [ + validateAndTransformQuery( + AdminGetProductOptionsParams, + QueryConfig.listProductOptionsTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/product-options/:id", + middlewares: [ + validateAndTransformQuery( + AdminGetProductOptionParams, + QueryConfig.retrieveProductOptionsTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/product-options", + middlewares: [ + validateAndTransformBody(AdminCreateProductOption), + validateAndTransformQuery( + AdminGetProductOptionParams, + QueryConfig.retrieveProductOptionsTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/product-options/:id", + middlewares: [ + validateAndTransformBody(AdminUpdateProductOption), + validateAndTransformQuery( + AdminGetProductOptionParams, + QueryConfig.retrieveProductOptionsTransformQueryConfig + ), + ], + }, + { + method: ["DELETE"], + matcher: "/admin/product-options/:id", + middlewares: [], + }, +] diff --git a/packages/medusa/src/api/admin/product-options/query-config.ts b/packages/medusa/src/api/admin/product-options/query-config.ts new file mode 100644 index 0000000000000..310d76257e970 --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/query-config.ts @@ -0,0 +1,20 @@ +export const defaultAdminProductOptionsFields = [ + "id", + "title", + "is_exclusive", + "values.*", + "created_at", + "updated_at", + "metadata", +] + +export const retrieveProductOptionsTransformQueryConfig = { + defaults: defaultAdminProductOptionsFields, + isList: false, +} + +export const listProductOptionsTransformQueryConfig = { + ...retrieveProductOptionsTransformQueryConfig, + defaultLimit: 20, + isList: true, +} diff --git a/packages/medusa/src/api/admin/product-options/route.ts b/packages/medusa/src/api/admin/product-options/route.ts new file mode 100644 index 0000000000000..911f2344d4463 --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/route.ts @@ -0,0 +1,49 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, + refetchEntities, + refetchEntity, +} from "@medusajs/framework/http" + +import { createProductOptionsWorkflow } from "@medusajs/core-flows" +import { HttpTypes } from "@medusajs/framework/types" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { data: product_options, metadata } = await refetchEntities({ + entity: "product_option", + idOrFilter: req.filterableFields, + scope: req.scope, + fields: req.queryConfig.fields, + pagination: req.queryConfig.pagination, + }) + + res.json({ + product_options, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const input = [req.validatedBody] + + const { result } = await createProductOptionsWorkflow(req.scope).run({ + input: { product_options: input }, + }) + + const productOption = await refetchEntity({ + entity: "product_option", + idOrFilter: result[0].id, + scope: req.scope, + fields: req.queryConfig.fields, + }) + + res.status(200).json({ product_option: productOption }) +} diff --git a/packages/medusa/src/api/admin/product-options/validators.ts b/packages/medusa/src/api/admin/product-options/validators.ts new file mode 100644 index 0000000000000..7c682e82ee333 --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/validators.ts @@ -0,0 +1,50 @@ +import { z } from "zod" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../utils/validators" +import { applyAndAndOrOperators } from "../../utils/common-validators" + +export type AdminGetProductOptionParamsType = z.infer< + typeof AdminGetProductOptionParams +> +export const AdminGetProductOptionParams = createSelectParams() + +export const AdminGetProductOptionsParamsFields = z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + title: z.union([z.string(), z.array(z.string())]).optional(), + is_exclusive: z.string().optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), +}) + +export const AdminGetProductOptionsParams = createFindParams({ + limit: 20, + offset: 0, +}) + .merge(AdminGetProductOptionsParamsFields) + .merge(applyAndAndOrOperators(AdminGetProductOptionsParamsFields)) + +export const AdminCreateProductOption = z + .object({ + title: z.string(), + values: z.array(z.string()), + is_exclusive: z.boolean().optional(), + metadata: z.record(z.unknown()).nullish(), + }) + .strict() + +export type AdminUpdateProductOptionType = z.infer< + typeof AdminUpdateProductOption +> +export const AdminUpdateProductOption = z + .object({ + title: z.string().optional(), + values: z.array(z.string()).optional(), + is_exclusive: z.boolean().optional(), + metadata: z.record(z.unknown()).nullish(), + }) + .strict() diff --git a/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts b/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts index 8d53278a9ab80..47fae3fc8184e 100644 --- a/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts @@ -1,83 +1,83 @@ -import { - AuthenticatedMedusaRequest, - MedusaResponse, - refetchEntity, -} from "@medusajs/framework/http" -import { - deleteProductOptionsWorkflow, - updateProductOptionsWorkflow, -} from "@medusajs/core-flows" - -import { remapKeysForProduct, remapProductResponse } from "../../../helpers" -import { AdditionalData, HttpTypes } from "@medusajs/framework/types" - -export const GET = async ( - req: AuthenticatedMedusaRequest, - res: MedusaResponse -) => { - const productId = req.params.id - const optionId = req.params.option_id - const productOption = await refetchEntity({ - entity: "product_option", - idOrFilter: { id: optionId, products: { id: productId } }, - scope: req.scope, - fields: req.queryConfig.fields, - }) - - res.status(200).json({ product_option: productOption }) -} - -export const POST = async ( - req: AuthenticatedMedusaRequest< - HttpTypes.AdminUpdateProductOption & AdditionalData - >, - res: MedusaResponse -) => { - const productId = req.params.id - const optionId = req.params.option_id - const { additional_data, ...update } = req.validatedBody - - await updateProductOptionsWorkflow(req.scope).run({ - input: { - selector: { id: optionId, product_id: productId }, - update, - additional_data, - }, - }) - - const product = await refetchEntity({ - entity: "product", - idOrFilter: productId, - scope: req.scope, - fields: remapKeysForProduct(req.queryConfig.fields ?? []), - }) - - res.status(200).json({ product: remapProductResponse(product) }) -} - -export const DELETE = async ( - req: AuthenticatedMedusaRequest, - res: MedusaResponse -) => { - const productId = req.params.id - const optionId = req.params.option_id - - // TODO: I believe here we cannot even enforce the product ID based on the standard API we provide? - await deleteProductOptionsWorkflow(req.scope).run({ - input: { ids: [optionId] /* product_id: productId */ }, - }) - - const product = await refetchEntity({ - entity: "product", - idOrFilter: productId, - scope: req.scope, - fields: remapKeysForProduct(req.queryConfig.fields ?? []), - }) - - res.status(200).json({ - id: optionId, - object: "product_option", - deleted: true, - parent: product, - }) -} +// import { +// AuthenticatedMedusaRequest, +// MedusaResponse, +// refetchEntity, +// } from "@medusajs/framework/http" +// import { +// deleteProductOptionsWorkflow, +// updateProductOptionsWorkflow, +// } from "@medusajs/core-flows" +// +// import { remapKeysForProduct, remapProductResponse } from "../../../helpers" +// import { AdditionalData, HttpTypes } from "@medusajs/framework/types" +// +// export const GET = async ( +// req: AuthenticatedMedusaRequest, +// res: MedusaResponse +// ) => { +// const productId = req.params.id +// const optionId = req.params.option_id +// const productOption = await refetchEntity({ +// entity: "product_option", +// idOrFilter: { id: optionId, products: { id: productId } }, +// scope: req.scope, +// fields: req.queryConfig.fields, +// }) +// +// res.status(200).json({ product_option: productOption }) +// } +// +// export const POST = async ( +// req: AuthenticatedMedusaRequest< +// HttpTypes.AdminUpdateProductOption & AdditionalData +// >, +// res: MedusaResponse +// ) => { +// const productId = req.params.id +// const optionId = req.params.option_id +// const { additional_data, ...update } = req.validatedBody +// +// await updateProductOptionsWorkflow(req.scope).run({ +// input: { +// selector: { id: optionId, product_id: productId }, +// update, +// additional_data, +// }, +// }) +// +// const product = await refetchEntity({ +// entity: "product", +// idOrFilter: productId, +// scope: req.scope, +// fields: remapKeysForProduct(req.queryConfig.fields ?? []), +// }) +// +// res.status(200).json({ product: remapProductResponse(product) }) +// } +// +// export const DELETE = async ( +// req: AuthenticatedMedusaRequest, +// res: MedusaResponse +// ) => { +// const productId = req.params.id +// const optionId = req.params.option_id +// +// // TODO: I believe here we cannot even enforce the product ID based on the standard API we provide? +// await deleteProductOptionsWorkflow(req.scope).run({ +// input: { ids: [optionId] /* product_id: productId */ }, +// }) +// +// const product = await refetchEntity({ +// entity: "product", +// idOrFilter: productId, +// scope: req.scope, +// fields: remapKeysForProduct(req.queryConfig.fields ?? []), +// }) +// +// res.status(200).json({ +// id: optionId, +// object: "product_option", +// deleted: true, +// parent: product, +// }) +// } diff --git a/packages/medusa/src/api/admin/products/[id]/options/route.ts b/packages/medusa/src/api/admin/products/[id]/options/route.ts index 4248733f6ea11..f9455a812cb6e 100644 --- a/packages/medusa/src/api/admin/products/[id]/options/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/options/route.ts @@ -1,61 +1,61 @@ -import { - AuthenticatedMedusaRequest, - MedusaResponse, - refetchEntities, - refetchEntity, -} from "@medusajs/framework/http" - -import { createProductOptionsWorkflow } from "@medusajs/core-flows" -import { remapKeysForProduct, remapProductResponse } from "../../helpers" -import { AdditionalData, HttpTypes } from "@medusajs/framework/types" - -export const GET = async ( - req: AuthenticatedMedusaRequest, - res: MedusaResponse -) => { - const productId = req.params.id - const { data: product_options, metadata } = await refetchEntities({ - entity: "product_option", - idOrFilter: { ...req.filterableFields, products: { id: productId } }, - scope: req.scope, - fields: req.queryConfig.fields, - pagination: req.queryConfig.pagination, - }) - - res.json({ - product_options, - count: metadata.count, - offset: metadata.skip, - limit: metadata.take, - }) -} - -export const POST = async ( - req: AuthenticatedMedusaRequest< - HttpTypes.AdminCreateProductOption & AdditionalData - >, - res: MedusaResponse -) => { - const productId = req.params.id - const { additional_data, ...rest } = req.validatedBody - - await createProductOptionsWorkflow(req.scope).run({ - input: { - product_options: [ - { - ...rest, - product_id: productId, - }, - ], - additional_data, - }, - }) - - const product = await refetchEntity({ - entity: "product", - idOrFilter: productId, - scope: req.scope, - fields: remapKeysForProduct(req.queryConfig.fields ?? []), - }) - res.status(200).json({ product: remapProductResponse(product) }) -} +// import { +// AuthenticatedMedusaRequest, +// MedusaResponse, +// refetchEntities, +// refetchEntity, +// } from "@medusajs/framework/http" +// +// import { createProductOptionsWorkflow } from "@medusajs/core-flows" +// import { remapKeysForProduct, remapProductResponse } from "../../helpers" +// import { AdditionalData, HttpTypes } from "@medusajs/framework/types" +// +// export const GET = async ( +// req: AuthenticatedMedusaRequest, +// res: MedusaResponse +// ) => { +// const productId = req.params.id +// const { data: product_options, metadata } = await refetchEntities({ +// entity: "product_option", +// idOrFilter: { ...req.filterableFields, products: { id: productId } }, +// scope: req.scope, +// fields: req.queryConfig.fields, +// pagination: req.queryConfig.pagination, +// }) +// +// res.json({ +// product_options, +// count: metadata.count, +// offset: metadata.skip, +// limit: metadata.take, +// }) +// } +// +// export const POST = async ( +// req: AuthenticatedMedusaRequest< +// HttpTypes.AdminCreateProductOption & AdditionalData +// >, +// res: MedusaResponse +// ) => { +// const productId = req.params.id +// const { additional_data, ...rest } = req.validatedBody +// +// await createProductOptionsWorkflow(req.scope).run({ +// input: { +// product_options: [ +// { +// ...rest, +// product_id: productId, +// }, +// ], +// additional_data, +// }, +// }) +// +// const product = await refetchEntity({ +// entity: "product", +// idOrFilter: productId, +// scope: req.scope, +// fields: remapKeysForProduct(req.queryConfig.fields ?? []), +// }) +// res.status(200).json({ product: remapProductResponse(product) }) +// } diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index 5d8c59b0c49d2..99b7a48dce781 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -22,6 +22,7 @@ import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middleware import { adminPricePreferencesRoutesMiddlewares } from "./admin/price-preferences/middlewares" import { adminProductCategoryRoutesMiddlewares } from "./admin/product-categories/middlewares" import { adminProductTagRoutesMiddlewares } from "./admin/product-tags/middlewares" +import { adminProductOptionRoutesMiddlewares } from "./admin/product-options/middlewares" import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares" import { adminProductVariantRoutesMiddlewares } from "./admin/product-variants/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" @@ -109,6 +110,7 @@ export default defineMiddlewares([ ...adminShippingOptionTypeRoutesMiddlewares, ...adminProductTypeRoutesMiddlewares, ...adminProductTagRoutesMiddlewares, + ...adminProductOptionRoutesMiddlewares, ...adminUploadRoutesMiddlewares, ...adminFulfillmentSetsRoutesMiddlewares, ...adminNotificationRoutesMiddlewares, diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 94698077fee6c..1f56ff8e71b89 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -818,36 +818,10 @@ export default class ProductModuleService values: opt.values?.map((v) => { return typeof v === "string" ? { value: v } : v }), - is_exclusive: true, // TODO - Next PR will update this when we actually support new global options } }) - const createdOptions = await this.productOptionService_.create( - normalizedInput, - sharedContext - ) - - // Create pivot table entries for options with product_id - const pivotEntries = data - .map((opt, index) => { - if (opt.product_id) { - return { - product: opt.product_id, - product_option: createdOptions[index].id, - } - } - return null - }) - .filter(Boolean) - - if (pivotEntries.length > 0) { - await this.productProductOptionService_.create( - pivotEntries, - sharedContext - ) - } - - return createdOptions + return this.productOptionService_.create(normalizedInput, sharedContext) } async upsertProductOptions( From c0af76418f19343af2fe337bf3b229502f42078d Mon Sep 17 00:00:00 2001 From: willbouch Date: Tue, 28 Oct 2025 07:44:49 -0400 Subject: [PATCH 09/36] product options endpoints --- .../product-option/product-option.spec.ts | 7 +- .../__tests__/product/admin/product.spec.ts | 57 ++--- .../core-flows/src/product/steps/index.ts | 1 + .../steps/link-product-options-to-product.ts | 77 +++++++ .../core-flows/src/product/workflows/index.ts | 1 + .../link-product-options-to-product.ts | 96 ++++++++ packages/core/types/src/product/common.ts | 25 +++ packages/core/types/src/product/service.ts | 209 +++++++++++++----- .../[id]/options/[option_id]/route.ts | 83 ------- .../api/admin/products/[id]/options/route.ts | 111 +++++----- .../src/api/admin/products/middlewares.ts | 38 +--- .../src/api/admin/products/validators.ts | 15 +- .../src/services/product-module-service.ts | 91 +++++++- 13 files changed, 519 insertions(+), 292 deletions(-) create mode 100644 packages/core/core-flows/src/product/steps/link-product-options-to-product.ts create mode 100644 packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts delete mode 100644 packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts diff --git a/integration-tests/http/__tests__/product-option/product-option.spec.ts b/integration-tests/http/__tests__/product-option/product-option.spec.ts index 38224a7f45b1a..a25b400b3cf10 100644 --- a/integration-tests/http/__tests__/product-option/product-option.spec.ts +++ b/integration-tests/http/__tests__/product-option/product-option.spec.ts @@ -113,7 +113,7 @@ medusaIntegrationTestRunner({ expect.objectContaining({ value: "B" }), expect.objectContaining({ value: "C" }), ]), - }), + }) ) }) }) @@ -153,10 +153,7 @@ medusaIntegrationTestRunner({ describe("DELETE /admin/product-options/[id]", () => { it("deletes a product option", async () => { - await api.delete( - `/admin/product-options/${option2.id}`, - adminHeaders - ) + await api.delete(`/admin/product-options/${option2.id}`, adminHeaders) const res = await api.get("/admin/product-options", adminHeaders) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 58274feea2073..e23b689d13c21 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -68,6 +68,22 @@ medusaIntegrationTestRunner({ ) ).data.product_tag + baseOption1 = ( + await api.post( + "/admin/product-options", + { title: "Size", values: ["S", "M", "L"] }, + adminHeaders + ) + ).data.product_option + + baseOption2 = ( + await api.post( + "/admin/product-options", + { title: "Color", values: ["Red", "Blue"] }, + adminHeaders + ) + ).data.product_option + shippingProfile = ( await api.post( `/admin/shipping-profiles`, @@ -85,6 +101,7 @@ medusaIntegrationTestRunner({ // BREAKING: Type input changed from {type: {value: string}} to {type_id: string} type_id: baseType.id, tags: [{ id: baseTag1.id }, { id: baseTag2.id }], + options: [{ id: baseTag1.id }, { id: baseTag2.id }], shipping_profile_id: shippingProfile.id, images: [ { @@ -712,15 +729,7 @@ medusaIntegrationTestRunner({ title: "Test Giftcard", is_giftcard: true, description: "test-giftcard-description", - options: [{ title: "size", values: ["x", "l"] }], shipping_profile_id: shippingProfile.id, - variants: [ - { - title: "Test variant", - prices: [{ currency_code: "usd", amount: 100 }], - options: { size: "x" }, - }, - ], } await api @@ -2598,38 +2607,6 @@ medusaIntegrationTestRunner({ ) }) - it.only("add option", async () => { - const payload = { - title: "should_add", - values: ["100"], - } - - const response = await api - .post( - `/admin/products/${baseProduct.id}/options`, - payload, - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - - expect(response.data.product).toEqual( - expect.objectContaining({ - options: expect.arrayContaining([ - expect.objectContaining({ - title: "should_add", - values: expect.arrayContaining([ - expect.objectContaining({ value: "100" }), - ]), - }), - ]), - }) - ) - }) - it("creates product with variant inventory kits", async () => { const inventoryItem1 = ( await api.post( diff --git a/packages/core/core-flows/src/product/steps/index.ts b/packages/core/core-flows/src/product/steps/index.ts index eb4cde0156b53..04fbee529ece2 100644 --- a/packages/core/core-flows/src/product/steps/index.ts +++ b/packages/core/core-flows/src/product/steps/index.ts @@ -1,3 +1,4 @@ +export * from "./link-product-options-to-product" export * from "./create-products" export * from "./update-products" export * from "./delete-products" diff --git a/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts b/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts new file mode 100644 index 0000000000000..f73f42eac4931 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts @@ -0,0 +1,77 @@ +import { IProductModuleService } from "@medusajs/framework/types" +import { Modules, promiseAll } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +/** + * The data to add/remove one or more product options to/from a product. + */ +export type LinkProductOptionsToProductStepInput = { + /** + * The product ID to add/remove the options to/from. + */ + product_id: string + /** + * The product options to add to the product. + */ + add?: string[] + /** + * The product options to remove from the product. + */ + remove?: string[] +} + +export const linkProductOptionsToProductStepId = + "link-product-options-to-product" +/** + * This step adds/removes one or more product options to/from a product. + * + * @example + * const data = linkProductOptionsToProductStep({ + * product_id: "prod_123", + * product_option_ids: ["opt_123", "opt_321"] + * }) + */ +export const linkProductOptionsToProductStep = createStep( + linkProductOptionsToProductStepId, + async (input: LinkProductOptionsToProductStepInput, { container }) => { + const service = container.resolve(Modules.PRODUCT) + + const toAdd = (input.add ?? []).map((optionId) => { + return { + product_option_id: optionId, + product_id: input.product_id, + } + }) + + const toRemove = (input.remove ?? []).map((optionId) => { + return { + product_option_id: optionId, + product_id: input.product_id, + } + }) + + const promises: Promise[] = [] + if (toAdd.length) { + promises.push(service.addProductOptionToProduct(toAdd)) + } + if (toRemove.length) { + promises.push(service.removeProductOptionFromProduct(toRemove)) + } + await promiseAll(promises) + + return new StepResponse(void 0, { toAdd, toRemove }) + }, + async (prevData, { container }) => { + if (!prevData) { + return + } + const service = container.resolve(Modules.PRODUCT) + + if (prevData.toAdd.length) { + await service.removeProductOptionFromProduct(prevData.toAdd) + } + if (prevData.toRemove.length) { + await service.addProductOptionToProduct(prevData.toRemove) + } + } +) diff --git a/packages/core/core-flows/src/product/workflows/index.ts b/packages/core/core-flows/src/product/workflows/index.ts index a6ad08bc98038..1de5e3bf3eae1 100644 --- a/packages/core/core-flows/src/product/workflows/index.ts +++ b/packages/core/core-flows/src/product/workflows/index.ts @@ -1,3 +1,4 @@ +export * from "./link-product-options-to-product" export * from "./batch-link-products-collection" export * from "./batch-product-variants" export * from "./batch-products" diff --git a/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts b/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts new file mode 100644 index 0000000000000..c90017d42e447 --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts @@ -0,0 +1,96 @@ +import type { ProductTypes } from "@medusajs/framework/types" +import { + createWorkflow, + transform, + when, + WorkflowData, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + createProductOptionsStep, + linkProductOptionsToProductStep, +} from "../steps" + +/** + * The data to add/remove one or more product options to/from a product. + */ +export type LinkProductOptionsToProductWorkflowInput = { + /** + * The product ID to add/remove the options to/from. + */ + product_id: string + /** + * The product options to add to the product. + */ + add?: (string | ProductTypes.CreateProductOptionDTO)[] + /** + * The product options to remove from the product. + */ + remove?: string[] +} + +export const linkProductOptionsToProductWorkflowId = + "link-product-options-to-product" +/** + * This workflow adds/removes one or more product options to/from a product. It's used by the [TODO](TODO). + * This workflow also creates non-existing product options before adding them to the product. + * + * You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around product-option and product association. + * + * @example + * const { result } = await linkProductOptionsToProductWorkflow(container) + * .run({ + * input: { + * product_id: "prod_123" + * add: [ + * { + * title: "Size", + * values: ["S", "M", "L", "XL"] + * }, + * { id: "opt_123" } + * ], + * remove: ["opt_321"] + * } + * }) + * + * @summary + * + * Add/remove one or more product options to/from a product. + */ +export const linkProductOptionsToProductWorkflow = createWorkflow( + linkProductOptionsToProductWorkflowId, + (input: WorkflowData) => { + const optionsToCreate = transform({ input }, ({ input }) => { + return (input.add ?? []).filter((option) => !(typeof option === "string")) + }) as ProductTypes.CreateProductOptionDTO[] + + const createdIds = when( + "creating-product-options", + { optionsToCreate }, + ({ optionsToCreate }) => optionsToCreate.length > 0 + ).then(() => { + const createdOptions = createProductOptionsStep(optionsToCreate) + + return transform({ createdOptions }, ({ createdOptions }) => { + return createdOptions.map((option) => option.id) + }) + }) + + const toAddProductOptionIds = transform( + { input, createdIds }, + ({ input, createdIds }) => { + return (input.add ?? []) + .filter((option) => typeof option === "string") + .concat(createdIds ? createdIds : []) + } + ) + + const productOptions = linkProductOptionsToProductStep({ + product_id: input.product_id, + add: toAddProductOptionIds, + remove: input.remove, + }) + + return new WorkflowResponse(productOptions) + } +) diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index 9abdc9345059b..d562707c61caa 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -1207,6 +1207,10 @@ export interface CreateProductOptionDTO { * Whether the product option is exclusive or global. */ is_exclusive?: boolean + /** + * The metadata of the product option. + */ + metadata?: MetadataType } export interface CreateProductOptionValueDTO { @@ -1247,6 +1251,10 @@ export interface UpdateProductOptionDTO { * Whether the product option is exclusive or global. */ is_exclusive?: boolean + /** + * The metadata of the product option. + */ + metadata?: MetadataType } export interface UpdateProductOptionValueDTO { @@ -1685,3 +1693,20 @@ export interface UpdateProductDTO { */ metadata?: MetadataType } + +/** + * @interface + * + * The details of a product option and product pair. + */ +export type ProductOptionProductPair = { + /** + * The product option's ID. + */ + product_option_id: string + + /** + * The product's ID. + */ + product_id: string +} diff --git a/packages/core/types/src/product/service.ts b/packages/core/types/src/product/service.ts index 5993cc142b836..70121b478884d 100644 --- a/packages/core/types/src/product/service.ts +++ b/packages/core/types/src/product/service.ts @@ -20,6 +20,7 @@ import { ProductCollectionDTO, ProductDTO, ProductOptionDTO, + ProductOptionProductPair, ProductOptionValueDTO, ProductTagDTO, ProductTypeDTO, @@ -68,12 +69,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -111,12 +112,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the products: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -172,12 +173,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the products: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -419,12 +420,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -462,12 +463,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product tags: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -523,12 +524,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product tags: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -1080,12 +1081,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -1185,14 +1186,14 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product options: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: - * + * * ```ts * const [options, count] = * await productModuleService.listAndCountProductOptions( @@ -1433,6 +1434,96 @@ export interface IProductModuleService extends IModuleService { sharedContext?: Context ): Promise | void> + /** + * This method adds a product option to a product. + * + * @param {ProductOptionProductPair} productOptionProductPair - The details of the product option and the product it should be added to. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise<{ id: string; }>} The ID of the relation between the product option and the product. + * + * @example + * const productOptionProductId = + * await productModuleService.addProductOptionToProduct({ + * product_id: "prod_123", + * product_option_id: "opt_123", + * }) + */ + addProductOptionToProduct( + productOptionProductPair: ProductOptionProductPair, + sharedContext?: Context + ): Promise<{ + /** + * The ID of the relation between the product option and the product. + */ + id: string + }> + + /** + * This method adds product options to a product. + * + * @param {ProductOptionProductPair[]} productOptionProductPairs - A list of items, each being the details of a product option and the product it should be added to. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise<{ id: string; }[]>} The IDs of the relations between each of the product option and product pairs. + * + * @example + * const productOptionProductIds = + * await productModuleService.addProductOptionToProduct([ + * { + * product_id: "prod_123", + * product_option_id: "opt_123", + * }, + * ]) + */ + addProductOptionToProduct( + productOptionProductPairs: ProductOptionProductPair[], + sharedContext?: Context + ): Promise< + { + /** + * The ID of the relation between the product option and the product. + */ + id: string + }[] + > + + /** + * This method removes a product option from a product. + * + * @param {ProductOptionProductPair} productOptionProductPair - The details of the product option and the product it should be removed from. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the product option is removed from the product successfully. + ** + * @example + * await productModuleService.removeProductOptionFromProduct({ + * product_id: "prod_123", + * product_option_id: "opt_123", + * }) + */ + removeProductOptionFromProduct( + productOptionProductPair: ProductOptionProductPair, + sharedContext?: Context + ): Promise + + /** + * This method removes product options from products. + * + * @param {ProductOptionProductPair[]} productOptionProductPairs - A list of items, each being the details of a product option and the product it should be removed from. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the product options are removed from the products successfully. + * + * @example + * await productModuleService.removeProductOptionFromProduct([ + * { + * product_id: "prod_123", + * product_option_id: "opt_123", + * }, + * ]) + */ + removeProductOptionFromProduct( + productOptionProductPairs: ProductOptionProductPair[], + sharedContext?: Context + ): Promise + /** * This method is used to retrieve a product option value by its ID. * @@ -1452,12 +1543,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -1553,14 +1644,14 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product option values: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: - * + * * ```ts * const [options, count] = * await productModuleService.listAndCountProductOptionValues( @@ -1772,12 +1863,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -1815,12 +1906,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product variants: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -1876,12 +1967,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product variants: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -2150,12 +2241,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -2192,12 +2283,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product collections: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -2255,12 +2346,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product collections: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -2518,12 +2609,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -2561,12 +2652,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product categories: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts @@ -2622,12 +2713,12 @@ export interface IProductModuleService extends IModuleService { * ``` * * To specify relations that should be retrieved within the product categories: - * + * * :::note - * + * * You can only retrieve data models defined in the same module. To retrieve linked data models * from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead. - * + * * ::: * * ```ts diff --git a/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts b/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts deleted file mode 100644 index 47fae3fc8184e..0000000000000 --- a/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts +++ /dev/null @@ -1,83 +0,0 @@ -// import { -// AuthenticatedMedusaRequest, -// MedusaResponse, -// refetchEntity, -// } from "@medusajs/framework/http" -// import { -// deleteProductOptionsWorkflow, -// updateProductOptionsWorkflow, -// } from "@medusajs/core-flows" -// -// import { remapKeysForProduct, remapProductResponse } from "../../../helpers" -// import { AdditionalData, HttpTypes } from "@medusajs/framework/types" -// -// export const GET = async ( -// req: AuthenticatedMedusaRequest, -// res: MedusaResponse -// ) => { -// const productId = req.params.id -// const optionId = req.params.option_id -// const productOption = await refetchEntity({ -// entity: "product_option", -// idOrFilter: { id: optionId, products: { id: productId } }, -// scope: req.scope, -// fields: req.queryConfig.fields, -// }) -// -// res.status(200).json({ product_option: productOption }) -// } -// -// export const POST = async ( -// req: AuthenticatedMedusaRequest< -// HttpTypes.AdminUpdateProductOption & AdditionalData -// >, -// res: MedusaResponse -// ) => { -// const productId = req.params.id -// const optionId = req.params.option_id -// const { additional_data, ...update } = req.validatedBody -// -// await updateProductOptionsWorkflow(req.scope).run({ -// input: { -// selector: { id: optionId, product_id: productId }, -// update, -// additional_data, -// }, -// }) -// -// const product = await refetchEntity({ -// entity: "product", -// idOrFilter: productId, -// scope: req.scope, -// fields: remapKeysForProduct(req.queryConfig.fields ?? []), -// }) -// -// res.status(200).json({ product: remapProductResponse(product) }) -// } -// -// export const DELETE = async ( -// req: AuthenticatedMedusaRequest, -// res: MedusaResponse -// ) => { -// const productId = req.params.id -// const optionId = req.params.option_id -// -// // TODO: I believe here we cannot even enforce the product ID based on the standard API we provide? -// await deleteProductOptionsWorkflow(req.scope).run({ -// input: { ids: [optionId] /* product_id: productId */ }, -// }) -// -// const product = await refetchEntity({ -// entity: "product", -// idOrFilter: productId, -// scope: req.scope, -// fields: remapKeysForProduct(req.queryConfig.fields ?? []), -// }) -// -// res.status(200).json({ -// id: optionId, -// object: "product_option", -// deleted: true, -// parent: product, -// }) -// } diff --git a/packages/medusa/src/api/admin/products/[id]/options/route.ts b/packages/medusa/src/api/admin/products/[id]/options/route.ts index f9455a812cb6e..64d2fb8c8a394 100644 --- a/packages/medusa/src/api/admin/products/[id]/options/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/options/route.ts @@ -1,61 +1,50 @@ -// import { -// AuthenticatedMedusaRequest, -// MedusaResponse, -// refetchEntities, -// refetchEntity, -// } from "@medusajs/framework/http" -// -// import { createProductOptionsWorkflow } from "@medusajs/core-flows" -// import { remapKeysForProduct, remapProductResponse } from "../../helpers" -// import { AdditionalData, HttpTypes } from "@medusajs/framework/types" -// -// export const GET = async ( -// req: AuthenticatedMedusaRequest, -// res: MedusaResponse -// ) => { -// const productId = req.params.id -// const { data: product_options, metadata } = await refetchEntities({ -// entity: "product_option", -// idOrFilter: { ...req.filterableFields, products: { id: productId } }, -// scope: req.scope, -// fields: req.queryConfig.fields, -// pagination: req.queryConfig.pagination, -// }) -// -// res.json({ -// product_options, -// count: metadata.count, -// offset: metadata.skip, -// limit: metadata.take, -// }) -// } -// -// export const POST = async ( -// req: AuthenticatedMedusaRequest< -// HttpTypes.AdminCreateProductOption & AdditionalData -// >, -// res: MedusaResponse -// ) => { -// const productId = req.params.id -// const { additional_data, ...rest } = req.validatedBody -// -// await createProductOptionsWorkflow(req.scope).run({ -// input: { -// product_options: [ -// { -// ...rest, -// product_id: productId, -// }, -// ], -// additional_data, -// }, -// }) -// -// const product = await refetchEntity({ -// entity: "product", -// idOrFilter: productId, -// scope: req.scope, -// fields: remapKeysForProduct(req.queryConfig.fields ?? []), -// }) -// res.status(200).json({ product: remapProductResponse(product) }) -// } +import { + AuthenticatedMedusaRequest, + MedusaResponse, + refetchEntities, + refetchEntity, +} from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" +import { remapKeysForProduct, remapProductResponse } from "../../helpers" +import { linkProductOptionsToProductWorkflow } from "@medusajs/core-flows" +import { AdminLinkProductOptionsType } from "../../validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const productId = req.params.id + const { data: product_options, metadata } = await refetchEntities({ + entity: "product_option", + idOrFilter: { ...req.filterableFields, products: { id: productId } }, + scope: req.scope, + fields: req.queryConfig.fields, + pagination: req.queryConfig.pagination, + }) + + res.json({ + product_options, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const productId = req.params.id + + await linkProductOptionsToProductWorkflow(req.scope).run({ + input: req.validatedBody, + }) + + const product = await refetchEntity({ + entity: "product", + idOrFilter: productId, + scope: req.scope, + fields: remapKeysForProduct(req.queryConfig.fields ?? []), + }) + res.status(200).json({ product: remapProductResponse(product) }) +} diff --git a/packages/medusa/src/api/admin/products/middlewares.ts b/packages/medusa/src/api/admin/products/middlewares.ts index 42ecb835b6230..7522f39cb322d 100644 --- a/packages/medusa/src/api/admin/products/middlewares.ts +++ b/packages/medusa/src/api/admin/products/middlewares.ts @@ -17,18 +17,16 @@ import { AdminBatchUpdateProductVariant, AdminBatchUpdateVariantInventoryItem, AdminCreateProduct, - AdminCreateProductOption, AdminCreateProductVariant, AdminCreateVariantInventoryItem, - AdminGetProductOptionParams, AdminGetProductOptionsParams, AdminGetProductParams, AdminGetProductsParams, AdminGetProductVariantParams, AdminGetProductVariantsParams, AdminImportProducts, + AdminLinkProductOptions, AdminUpdateProduct, - AdminUpdateProductOption, AdminUpdateProductVariant, AdminUpdateVariantInventoryItem, CreateProduct, @@ -224,43 +222,11 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, - // Note: New endpoint in v2 - { - method: ["GET"], - matcher: "/admin/products/:id/options/:option_id", - middlewares: [ - validateAndTransformQuery( - AdminGetProductOptionParams, - QueryConfig.retrieveOptionConfig - ), - ], - }, { method: ["POST"], matcher: "/admin/products/:id/options", middlewares: [ - validateAndTransformBody(AdminCreateProductOption), - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig - ), - ], - }, - { - method: ["POST"], - matcher: "/admin/products/:id/options/:option_id", - middlewares: [ - validateAndTransformBody(AdminUpdateProductOption), - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig - ), - ], - }, - { - method: ["DELETE"], - matcher: "/admin/products/:id/options/:option_id", - middlewares: [ + validateAndTransformBody(AdminLinkProductOptions), validateAndTransformQuery( AdminGetProductParams, QueryConfig.retrieveProductQueryConfig diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index f343f2f1bbcbe..a355f98c1470a 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -13,6 +13,7 @@ import { createSelectParams, WithAdditionalData, } from "../../utils/validators" +import { AdminCreateProductOption } from "../product-options/validators" const statusEnum = z.nativeEnum(ProductStatus) @@ -100,12 +101,14 @@ export const AdminUpdateProductTag = z.object({ value: z.string().optional(), }) -export type AdminCreateProductOptionType = z.infer -export const CreateProductOption = z.object({ - title: z.string(), - values: z.array(z.string()), +export type AdminLinkProductOptionsType = z.infer< + typeof AdminLinkProductOptions +> +export const AdminLinkProductOptions = z.object({ + product_id: z.string(), + add: z.array(z.union([z.string(), AdminCreateProductOption])).optional(), + remove: z.array(z.string()).optional(), }) -export const AdminCreateProductOption = WithAdditionalData(CreateProductOption) export type AdminUpdateProductOptionType = z.infer export const UpdateProductOption = z.object({ @@ -236,7 +239,7 @@ export const CreateProduct = z collection_id: z.string().nullish(), categories: z.array(IdAssociation).optional(), tags: z.array(IdAssociation).optional(), - options: z.array(CreateProductOption).optional(), + options: z.array(AdminCreateProductOption).optional(), variants: z.array(CreateProductVariant).optional(), sales_channels: z.array(z.object({ id: z.string() })).optional(), shipping_profile_id: z.string().optional(), diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 1f56ff8e71b89..e280bc4dc4bcc 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -987,6 +987,93 @@ export default class ProductModuleService return productOptions } + async addProductOptionToProduct( + productOptionProductPair: ProductTypes.ProductOptionProductPair, + sharedContext?: Context + ): Promise<{ id: string }> + + async addProductOptionToProduct( + productOptionProductPairs: ProductTypes.ProductOptionProductPair[], + sharedContext?: Context + ): Promise<{ id: string }[]> + + @InjectManager() + @EmitEvents() + async addProductOptionToProduct( + data: + | ProductTypes.ProductOptionProductPair + | ProductTypes.ProductOptionProductPair[], + @MedusaContext() sharedContext: Context = {} + ): Promise<{ id: string } | { id: string }[]> { + const productOptionProducts = await this.addProductOptionToProduct_( + data, + sharedContext + ) + + return productOptionProducts + } + + @InjectTransactionManager() + protected async addProductOptionToProduct_( + data: + | ProductTypes.ProductOptionProductPair + | ProductTypes.ProductOptionProductPair[], + @MedusaContext() sharedContext: Context = {} + ): Promise<{ id: string } | { id: string }[]> { + const productOptionProducts = + await this.productProductOptionService_.create(data, sharedContext) + + if (Array.isArray(data)) { + return ( + productOptionProducts as unknown as InferEntityType< + typeof ProductProductOption + >[] + ).map((ppo) => ({ id: ppo.id })) + } + + return { id: productOptionProducts.id } + } + + async removeProductOptionFromProduct( + groupCustomerPair: ProductTypes.ProductOptionProductPair, + sharedContext?: Context + ): Promise + + async removeProductOptionFromProduct( + groupCustomerPairs: ProductTypes.ProductOptionProductPair[], + sharedContext?: Context + ): Promise + + @InjectManager() + @EmitEvents() + async removeProductOptionFromProduct( + data: + | ProductTypes.ProductOptionProductPair + | ProductTypes.ProductOptionProductPair[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.removeProductOptionFromProduct_(data, sharedContext) + } + + @InjectTransactionManager() + protected async removeProductOptionFromProduct_( + data: + | ProductTypes.ProductOptionProductPair + | ProductTypes.ProductOptionProductPair[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const pairs = Array.isArray(data) ? data : [data] + const productOptionsProducts = await this.productProductOptionService_.list( + { + $or: pairs, + } + ) + await this.productProductOptionService_.delete( + productOptionsProducts.map(({ id }) => id), + sharedContext + ) + } + // @ts-expect-error createProductCollections( data: ProductTypes.CreateProductCollectionDTO[], @@ -1980,8 +2067,8 @@ export default class ProductModuleService } if (productData.tag_ids) { - ;(productData as any).tags = productData.tag_ids.map((cid) => ({ - id: cid, + ;(productData as any).tags = productData.tag_ids.map((tid) => ({ + id: tid, })) delete productData.tag_ids } From b4020d60702ab5aaef7cf33d44d1d41c8f3423b6 Mon Sep 17 00:00:00 2001 From: willbouch Date: Tue, 28 Oct 2025 08:43:27 -0400 Subject: [PATCH 10/36] tests --- .../__tests__/product/admin/product.spec.ts | 59 -------------- .../src/services/product-module-service.ts | 78 +++++++++++++++---- 2 files changed, 63 insertions(+), 74 deletions(-) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index e23b689d13c21..8fbbf21aa9a00 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -68,22 +68,6 @@ medusaIntegrationTestRunner({ ) ).data.product_tag - baseOption1 = ( - await api.post( - "/admin/product-options", - { title: "Size", values: ["S", "M", "L"] }, - adminHeaders - ) - ).data.product_option - - baseOption2 = ( - await api.post( - "/admin/product-options", - { title: "Color", values: ["Red", "Blue"] }, - adminHeaders - ) - ).data.product_option - shippingProfile = ( await api.post( `/admin/shipping-profiles`, @@ -101,7 +85,6 @@ medusaIntegrationTestRunner({ // BREAKING: Type input changed from {type: {value: string}} to {type_id: string} type_id: baseType.id, tags: [{ id: baseTag1.id }, { id: baseTag2.id }], - options: [{ id: baseTag1.id }, { id: baseTag2.id }], shipping_profile_id: shippingProfile.id, images: [ { @@ -2833,48 +2816,6 @@ medusaIntegrationTestRunner({ }) }) - describe("DELETE /admin/products/:id/options/:option_id", () => { - it("deletes a product option", async () => { - const response = await api - .delete( - `/admin/products/${baseProduct.id}/options/${baseProduct.options[0].id}`, - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - // BREAKING: Delete response changed from returning the deleted product to the current DeleteResponse model - expect(response.data).toEqual( - expect.objectContaining({ - id: baseProduct.options[0].id, - object: "product_option", - parent: expect.objectContaining({ - id: baseProduct.id, - }), - }) - ) - }) - - // TODO: This is failing, investigate - it.skip("deletes a values associated with deleted option", async () => { - await api.delete( - `/admin/products/${baseProduct.id}/options/${baseProduct.options[0].id}`, - adminHeaders - ) - - const optionsRes = await api.get( - `/admin/products/${baseProduct.id}/options?deleted_at[$gt]=01-26-1990`, - adminHeaders - ) - - expect(optionsRes.data.product_options).toEqual([ - expect.objectContaining({ deleted_at: expect.any(Date) }), - ]) - }) - }) - describe("testing for soft-deletion + uniqueness on handles, collection and variant properties", () => { it("successfully deletes a product", async () => { const response = await api diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index e280bc4dc4bcc..1cd527180844b 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -1649,7 +1649,7 @@ export default class ProductModuleService data: ProductTypes.CreateProductDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { - const normalizedProducts = await this.normalizeCreateProductInput( + const normalizedProducts = this.normalizeCreateProductInput( data, sharedContext ) @@ -1675,6 +1675,11 @@ export default class ProductModuleService const existingTagsMap = new Map(existingTags.map((tag) => [tag.id, tag])) + const productOptionsToCreate = new Map< + string, + ProductTypes.CreateProductOptionDTO[] + >() + const productsToCreate = normalizedProducts.map((product) => { const productId = generateEntityId(product.id, "prod") product.id = productId @@ -1685,6 +1690,10 @@ export default class ProductModuleService ) } + if (product.options?.length) { + productOptionsToCreate.set(productId, product.options as any) + } + if (product.variants?.length) { const normalizedVariants = product.variants.map((variant) => { const variantId = generateEntityId((variant as any).id, "variant") @@ -1725,13 +1734,55 @@ export default class ProductModuleService ) } + delete product.options + return product }) - const createdProducts = await this.productService_.create( - productsToCreate, - sharedContext - ) + const productToOptionIdsMap = new Map() + const allOptionsWithIds: (ProductTypes.CreateProductOptionDTO & { + id: string + })[] = [] + + for (const [productId, options] of productOptionsToCreate.entries()) { + const optionIds: string[] = [] + + for (const option of options) { + const optionId = generateEntityId((option as any).id, "opt") + optionIds.push(optionId) + allOptionsWithIds.push({ + ...option, + id: optionId, + }) + } + + productToOptionIdsMap.set(productId, optionIds) + } + + // Create products and options in parallel since IDs are pre-generated + const [createdProducts] = await Promise.all([ + this.productService_.create(productsToCreate, sharedContext), + allOptionsWithIds.length > 0 + ? this.createOptions_(allOptionsWithIds, sharedContext) + : Promise.resolve([]), + ]) + + const linkPairs: ProductTypes.ProductOptionProductPair[] = [] + for (const product of createdProducts) { + const optionIds = productToOptionIdsMap.get(product.id) + if (optionIds) { + for (const optionId of optionIds) { + linkPairs.push({ + product_id: product.id, + product_option_id: optionId, + }) + } + } + } + + if (linkPairs.length > 0) { + await this.addProductOptionToProduct_(linkPairs, sharedContext) + } return createdProducts } @@ -1773,7 +1824,7 @@ export default class ProductModuleService sharedContext ) - const normalizedProducts = await this.normalizeUpdateProductInput( + const normalizedProducts = this.normalizeUpdateProductInput( data, originalProducts ) @@ -1946,20 +1997,17 @@ export default class ProductModuleService this.validateProductPayload(productData) } - protected async normalizeCreateProductInput< + protected normalizeCreateProductInput< T extends ProductTypes.CreateProductDTO | ProductTypes.CreateProductDTO[], TOutput = T extends ProductTypes.CreateProductDTO[] ? ProductTypes.CreateProductDTO[] : ProductTypes.CreateProductDTO - >( - products: T, - @MedusaContext() sharedContext: Context = {} - ): Promise { + >(products: T, @MedusaContext() sharedContext: Context = {}): TOutput { const products_ = Array.isArray(products) ? products : [products] - const normalizedProducts = (await this.normalizeUpdateProductInput( + const normalizedProducts = this.normalizeUpdateProductInput( products_ as UpdateProductInput[] - )) as ProductTypes.CreateProductDTO[] + ) as ProductTypes.CreateProductDTO[] for (const productData of normalizedProducts) { if (!productData.handle && productData.title) { @@ -2012,7 +2060,7 @@ export default class ProductModuleService * @param originalProducts - The original products to use for the normalization (must include options and option values relations) * @returns The normalized products */ - protected async normalizeUpdateProductInput< + protected normalizeUpdateProductInput< T extends UpdateProductInput | UpdateProductInput[], TOutput = T extends UpdateProductInput[] ? UpdateProductInput[] @@ -2020,7 +2068,7 @@ export default class ProductModuleService >( products: T, originalProducts?: InferEntityType[] - ): Promise { + ): TOutput { const products_ = Array.isArray(products) ? products : [products] const productsIds = products_.map((p) => p.id).filter(Boolean) From cdd2dec4933d173c984826ff90e1c5d0b90cb321 Mon Sep 17 00:00:00 2001 From: willbouch Date: Tue, 28 Oct 2025 08:43:50 -0400 Subject: [PATCH 11/36] remove debug --- integration-tests/http/__tests__/product/admin/product.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 8fbbf21aa9a00..58fa8a60f3acb 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -8,7 +8,6 @@ import { getProductFixture } from "../../../../helpers/fixtures" jest.setTimeout(50000) medusaIntegrationTestRunner({ - debug: true, testSuite: ({ dbConnection, getContainer, api }) => { let baseProduct let proposedProduct From 5f064d4ee143e6dc15149067a8ccba216c8c88fb Mon Sep 17 00:00:00 2001 From: willbouch Date: Tue, 28 Oct 2025 14:00:33 -0400 Subject: [PATCH 12/36] small changes in endpoints --- .../medusa/src/api/admin/product-options/query-config.ts | 1 + .../medusa/src/api/admin/product-options/validators.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/medusa/src/api/admin/product-options/query-config.ts b/packages/medusa/src/api/admin/product-options/query-config.ts index 310d76257e970..d3bca4cf41765 100644 --- a/packages/medusa/src/api/admin/product-options/query-config.ts +++ b/packages/medusa/src/api/admin/product-options/query-config.ts @@ -3,6 +3,7 @@ export const defaultAdminProductOptionsFields = [ "title", "is_exclusive", "values.*", + "products.*", "created_at", "updated_at", "metadata", diff --git a/packages/medusa/src/api/admin/product-options/validators.ts b/packages/medusa/src/api/admin/product-options/validators.ts index 7c682e82ee333..6291477b40c5d 100644 --- a/packages/medusa/src/api/admin/product-options/validators.ts +++ b/packages/medusa/src/api/admin/product-options/validators.ts @@ -4,7 +4,10 @@ import { createOperatorMap, createSelectParams, } from "../../utils/validators" -import { applyAndAndOrOperators } from "../../utils/common-validators" +import { + applyAndAndOrOperators, + booleanString, +} from "../../utils/common-validators" export type AdminGetProductOptionParamsType = z.infer< typeof AdminGetProductOptionParams @@ -15,7 +18,7 @@ export const AdminGetProductOptionsParamsFields = z.object({ q: z.string().optional(), id: z.union([z.string(), z.array(z.string())]).optional(), title: z.union([z.string(), z.array(z.string())]).optional(), - is_exclusive: z.string().optional(), + is_exclusive: booleanString().optional(), created_at: createOperatorMap().optional(), updated_at: createOperatorMap().optional(), deleted_at: createOperatorMap().optional(), From 14e477813dca7329316a8213abebd2a2444f772f Mon Sep 17 00:00:00 2001 From: willbouch Date: Wed, 29 Oct 2025 13:09:40 -0400 Subject: [PATCH 13/36] add ranks to product option values --- .../product-option/product-option.spec.ts | 90 +++++++++++++++ .../product/steps/create-product-options.ts | 9 +- .../product/steps/update-product-options.ts | 7 +- .../workflows/create-product-options.ts | 7 +- .../types/src/http/product/admin/payloads.ts | 8 ++ .../core/types/src/http/product/common.ts | 4 + packages/core/types/src/product/common.ts | 12 ++ .../api/admin/product-options/validators.ts | 2 + .../migrations/.snapshot-medusa-product.json | 21 ++-- .../src/migrations/Migration20251029150809.ts | 13 +++ .../src/models/product-option-value.ts | 1 + .../src/services/product-module-service.ts | 103 +++++++++++++----- 12 files changed, 230 insertions(+), 47 deletions(-) create mode 100644 packages/modules/product/src/migrations/Migration20251029150809.ts diff --git a/integration-tests/http/__tests__/product-option/product-option.spec.ts b/integration-tests/http/__tests__/product-option/product-option.spec.ts index a25b400b3cf10..d051d6657d635 100644 --- a/integration-tests/http/__tests__/product-option/product-option.spec.ts +++ b/integration-tests/http/__tests__/product-option/product-option.spec.ts @@ -96,6 +96,64 @@ medusaIntegrationTestRunner({ }) }) + describe("POST /admin/product-options", () => { + it("creates a product option with value ranks", async () => { + const option = ( + await api.post( + `/admin/product-options`, + { + title: "option3", + values: ["D", "E"], + ranks: { + E: 1, + D: 2, + }, + }, + adminHeaders + ) + ).data.product_option + + expect(option).toEqual( + expect.objectContaining({ + title: "option3", + is_exclusive: false, + values: expect.arrayContaining([ + expect.objectContaining({ + value: "D", + rank: 2, + }), + expect.objectContaining({ + value: "E", + rank: 1, + }), + ]), + }) + ) + }) + + it("throws ir a rank is specified for invalid value", async () => { + const error = await api + .post( + `/admin/product-options`, + { + title: "option3", + values: ["D", "E"], + ranks: { + E: 1, + invalid: 2, + }, + }, + adminHeaders + ) + .catch((err) => err) + + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual( + 'Value "invalid" is assigned a rank but is not defined in the list of values.' + ) + }) + }) + describe("GET /admin/product-options/[id]", () => { it("returns a product option", async () => { const res = await api.get( @@ -149,6 +207,38 @@ medusaIntegrationTestRunner({ expect(res.status).toEqual(200) expect(res.data.product_options.length).toEqual(0) }) + + it("updates a product value ranks", async () => { + const option = ( + await api.post( + `/admin/product-options/${option2.id}`, + { + ranks: { + D: 2, + E: 1, + }, + }, + adminHeaders + ) + ).data.product_option + + expect(option).toEqual( + expect.objectContaining({ + title: "option2", + is_exclusive: true, + values: expect.arrayContaining([ + expect.objectContaining({ + value: "D", + rank: 2, + }), + expect.objectContaining({ + value: "E", + rank: 1, + }), + ]), + }) + ) + }) }) describe("DELETE /admin/product-options/[id]", () => { diff --git a/packages/core/core-flows/src/product/steps/create-product-options.ts b/packages/core/core-flows/src/product/steps/create-product-options.ts index ac311c1b80ee4..7451e9234751a 100644 --- a/packages/core/core-flows/src/product/steps/create-product-options.ts +++ b/packages/core/core-flows/src/product/steps/create-product-options.ts @@ -3,7 +3,7 @@ import type { ProductTypes, } from "@medusajs/framework/types" import { Modules } from "@medusajs/framework/utils" -import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" export const createProductOptionsStepId = "create-product-options" /** @@ -12,7 +12,12 @@ export const createProductOptionsStepId = "create-product-options" * @example * const data = createProductOptionsStep([{ * title: "Size", - * values: ["S", "M", "L"] + * values: ["S", "M", "L"], + * ranks: { + * "S": 2, + * "M": 1, + * "L": 3 + * } * }]) */ export const createProductOptionsStep = createStep( diff --git a/packages/core/core-flows/src/product/steps/update-product-options.ts b/packages/core/core-flows/src/product/steps/update-product-options.ts index 4fe1caf68540c..fe67f776fc66d 100644 --- a/packages/core/core-flows/src/product/steps/update-product-options.ts +++ b/packages/core/core-flows/src/product/steps/update-product-options.ts @@ -41,13 +41,12 @@ export const updateProductOptionsStep = createStep( async (data: UpdateProductOptionsStepInput, { container }) => { const service = container.resolve(Modules.PRODUCT) - const { selects, relations } = getSelectsAndRelationsFromObjectArray([ - data.update, - ]) + const { ranks, ...cleanedUpdate } = data.update + const { selects } = getSelectsAndRelationsFromObjectArray([cleanedUpdate]) const prevData = await service.listProductOptions(data.selector, { select: selects, - relations, + relations: ["values"], }) const productOptions = await service.updateProductOptions( diff --git a/packages/core/core-flows/src/product/workflows/create-product-options.ts b/packages/core/core-flows/src/product/workflows/create-product-options.ts index 958e46f55b949..fe981f72ffe22 100644 --- a/packages/core/core-flows/src/product/workflows/create-product-options.ts +++ b/packages/core/core-flows/src/product/workflows/create-product-options.ts @@ -41,7 +41,12 @@ export const createProductOptionsWorkflowId = "create-product-options" * { * title: "Color", * values: ["Red", "Blue", "Green"] - * is_exclusive: true + * is_exclusive: true, + * ranks: { + * "Red": 2, + * "Blue": 1, + * "Green": 3 + * } * } * ], * additional_data: { diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index c9243752901fa..d331fcc667e7d 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -515,6 +515,10 @@ export interface AdminCreateProductOption { * The option's values. */ values: string[] + /** + * The rank for each option value. + */ + ranks?: Record /** * Whether the option is exclusive or global. */ @@ -530,6 +534,10 @@ export interface AdminUpdateProductOption { * The option's values. */ values?: string[] + /** + * The rank for each option value. + */ + ranks?: Record /** * Whether the option is exclusive or global. */ diff --git a/packages/core/types/src/http/product/common.ts b/packages/core/types/src/http/product/common.ts index 8955f97ac0416..9eef57be3de03 100644 --- a/packages/core/types/src/http/product/common.ts +++ b/packages/core/types/src/http/product/common.ts @@ -326,6 +326,10 @@ export interface BaseProductOptionValue { * The option's value. */ value: string + /** + * The option's rank. + */ + rank?: number /** * The option's details. */ diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index d562707c61caa..b0b39bd36659f 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -1203,6 +1203,10 @@ export interface CreateProductOptionDTO { * The product option values. */ values: string[] + /** + * The rank for each option value. + */ + ranks?: Record /** * Whether the product option is exclusive or global. */ @@ -1218,6 +1222,10 @@ export interface CreateProductOptionValueDTO { * The value of the product option value. */ value: string + /** + * The rank of the product option value. + */ + rank?: number /** * The metadata of the product option value. */ @@ -1247,6 +1255,10 @@ export interface UpdateProductOptionDTO { * The product option values. */ values?: string[] + /** + * The rank for each option value. + */ + ranks?: Record /** * Whether the product option is exclusive or global. */ diff --git a/packages/medusa/src/api/admin/product-options/validators.ts b/packages/medusa/src/api/admin/product-options/validators.ts index 6291477b40c5d..701b298591af2 100644 --- a/packages/medusa/src/api/admin/product-options/validators.ts +++ b/packages/medusa/src/api/admin/product-options/validators.ts @@ -35,6 +35,7 @@ export const AdminCreateProductOption = z .object({ title: z.string(), values: z.array(z.string()), + ranks: z.record(z.number()).optional(), is_exclusive: z.boolean().optional(), metadata: z.record(z.unknown()).nullish(), }) @@ -47,6 +48,7 @@ export const AdminUpdateProductOption = z .object({ title: z.string().optional(), values: z.array(z.string()).optional(), + ranks: z.record(z.number()).optional(), is_exclusive: z.boolean().optional(), metadata: z.record(z.unknown()).nullish(), }) diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 1fc3aa7357840..01ea90efd5697 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -195,7 +195,6 @@ "id" ], "referencedTableName": "public.product_category", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -428,6 +427,15 @@ "nullable": false, "mappedType": "text" }, + "rank": { + "name": "rank", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "integer" + }, "metadata": { "name": "metadata", "type": "jsonb", @@ -532,7 +540,6 @@ "id" ], "referencedTableName": "public.product_option", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1039,7 +1046,6 @@ "id" ], "referencedTableName": "public.product_type", - "createForeignKeyConstraint": true, "deleteRule": "set null", "updateRule": "cascade" }, @@ -1053,7 +1059,6 @@ "id" ], "referencedTableName": "public.product_collection", - "createForeignKeyConstraint": true, "deleteRule": "set null", "updateRule": "cascade" } @@ -1328,7 +1333,6 @@ "id" ], "referencedTableName": "public.product", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1383,7 +1387,6 @@ "id" ], "referencedTableName": "public.product", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" }, @@ -1397,7 +1400,6 @@ "id" ], "referencedTableName": "public.product_tag", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1452,7 +1454,6 @@ "id" ], "referencedTableName": "public.product", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" }, @@ -1466,7 +1467,6 @@ "id" ], "referencedTableName": "public.product_category", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1771,7 +1771,6 @@ "id" ], "referencedTableName": "public.product", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } @@ -1826,7 +1825,6 @@ "id" ], "referencedTableName": "public.product_variant", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" }, @@ -1840,7 +1838,6 @@ "id" ], "referencedTableName": "public.product_option_value", - "createForeignKeyConstraint": true, "deleteRule": "cascade", "updateRule": "cascade" } diff --git a/packages/modules/product/src/migrations/Migration20251029150809.ts b/packages/modules/product/src/migrations/Migration20251029150809.ts new file mode 100644 index 0000000000000..d6e3011e24926 --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20251029150809.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251029150809 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "product_option_value" add column if not exists "rank" integer null;`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "product_option_value" drop column if exists "rank";`); + } + +} diff --git a/packages/modules/product/src/models/product-option-value.ts b/packages/modules/product/src/models/product-option-value.ts index 21e0aa7461191..8b618cea4b3f3 100644 --- a/packages/modules/product/src/models/product-option-value.ts +++ b/packages/modules/product/src/models/product-option-value.ts @@ -5,6 +5,7 @@ const ProductOptionValue = model .define("ProductOptionValue", { id: model.id({ prefix: "optval" }).primaryKey(), value: model.text(), + rank: model.number().nullable(), metadata: model.json().nullable(), option: model .belongsTo(() => ProductOption, { diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 1cd527180844b..7628db9c5494a 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -813,10 +813,26 @@ export default class ProductModuleService @MedusaContext() sharedContext: Context = {} ): Promise[]> { const normalizedInput = data.map((opt) => { + Object.keys(opt.ranks ?? []).forEach((value) => { + if (!opt.values.includes(value)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Value "${value}" is assigned a rank but is not defined in the list of values.` + ) + } + }) + return { ...opt, values: opt.values?.map((v) => { - return typeof v === "string" ? { value: v } : v + // Normalize each value into an object and attach rank if available + const valueObj = typeof v === "string" ? { value: v } : v + const rank = + opt.ranks && typeof v === "string" + ? opt.ranks[v] + : opt.ranks?.[valueObj.value] + + return rank !== undefined ? { ...valueObj, rank } : valueObj }), } }) @@ -945,35 +961,66 @@ export default class ProductModuleService // Data normalization const normalizedInput = data.map((opt) => { - const dbValues = dbOptions.find(({ id }) => id === opt.id)?.values || [] - const normalizedValues = opt.values?.map((v) => { - return typeof v === "string" ? { value: v } : v - }) + const dbOption = dbOptions.find(({ id }) => id === opt.id) + const dbValues = dbOption?.values || [] + + if (opt.ranks) { + const validValues = opt.values ?? dbValues.map((v) => v.value) + + Object.keys(opt.ranks).forEach((value) => { + if (!validValues.includes(value)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Value "${value}" is assigned a rank but is not defined in the list of values.` + ) + } + }) + } + + let normalizedValues + if (opt.values) { + // If new values are provided → normalize and apply ranks + normalizedValues = opt.values.map((v) => { + const valueObj = typeof v === "string" ? { value: v } : v + + const rank = + opt.ranks && typeof v === "string" + ? opt.ranks[v] + : opt.ranks?.[valueObj.value] + const rankedValue = + rank !== undefined ? { ...valueObj, rank } : valueObj + + if ("id" in rankedValue) { + return rankedValue + } + + const dbVal = dbValues.find( + (dbVal) => dbVal.value === rankedValue.value + ) + if (!dbVal) { + return rankedValue + } + + return { + id: dbVal.id, + ...rankedValue, + } + }) + } else if (opt.ranks) { + // If only ranks were provided → update existing DB values with ranks + normalizedValues = dbValues.map((dbVal) => { + const rank = opt.ranks![dbVal.value] + return rank !== undefined + ? { id: dbVal.id, value: dbVal.value, rank } + : { id: dbVal.id, value: dbVal.value } + }) + } + + const { ranks, ...cleanOpt } = opt return { - ...opt, - ...(normalizedValues - ? { - // Oftentimes the options are only passed by value without an id, even if they exist in the DB - values: normalizedValues.map((normVal) => { - if ("id" in normVal) { - return normVal - } - - const dbVal = dbValues.find( - (dbVal) => dbVal.value === normVal.value - ) - if (!dbVal) { - return normVal - } - - return { - id: dbVal.id, - value: normVal.value, - } - }), - } - : {}), + ...cleanOpt, + ...(normalizedValues ? { values: normalizedValues } : {}), } as UpdateProductOptionInput }) From 60b34307033f2432d1125224ced2b9d9fea1f256 Mon Sep 17 00:00:00 2001 From: willbouch Date: Fri, 31 Oct 2025 09:36:22 -0400 Subject: [PATCH 14/36] allow linking to existing option on product creation --- .../types/src/http/product/admin/payloads.ts | 2 +- packages/core/types/src/product/common.ts | 4 +- .../src/api/admin/products/validators.ts | 4 +- .../src/services/product-module-service.ts | 99 ++++++++++++++++--- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index d331fcc667e7d..6fe99c2ec9c0d 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -235,7 +235,7 @@ export interface AdminCreateProduct { /** * The product's options. */ - options: AdminCreateProductOption[] + options: (AdminCreateProductOption | { id: string })[] /** * The product's variants. */ diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index b0b39bd36659f..2d8b215c272ac 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -1540,9 +1540,9 @@ export interface CreateProductDTO { */ category_ids?: string[] /** - * The product options to be created and associated with the product. + * The product options to be created and/or associated with the product. */ - options?: CreateProductOptionDTO[] + options?: (CreateProductOptionDTO | { id: string })[] /** * The product variants to be created and associated with the product. */ diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index a355f98c1470a..77151a4bf2054 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -239,7 +239,9 @@ export const CreateProduct = z collection_id: z.string().nullish(), categories: z.array(IdAssociation).optional(), tags: z.array(IdAssociation).optional(), - options: z.array(AdminCreateProductOption).optional(), + options: z + .array(z.union([AdminCreateProductOption, z.object({ id: z.string() })])) + .optional(), variants: z.array(CreateProductVariant).optional(), sales_channels: z.array(z.object({ id: z.string() })).optional(), shipping_profile_id: z.string().optional(), diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 7628db9c5494a..9c921fab0312c 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -58,6 +58,7 @@ import { import { joinerConfig } from "./../joiner-config" import { eventBuilders } from "../utils/events" import { EntityManager } from "@mikro-orm/core" +import { CreateProductOptionDTO } from "@medusajs/types" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -1696,8 +1697,60 @@ export default class ProductModuleService data: ProductTypes.CreateProductDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { + const existingOptionIds = data + .flatMap((p) => p.options ?? []) + .filter((o) => "id" in o) + .map((o) => o.id) + + let existingOptions: InferEntityType[] = [] + if (existingOptionIds.length > 0) { + existingOptions = await this.productOptionService_.list( + { id: existingOptionIds }, + { relations: ["values"] }, + sharedContext + ) + + const fetchedIds = new Set(existingOptions.map((opt) => opt.id)) + const missingIds = existingOptionIds.filter((id) => !fetchedIds.has(id)) + if (missingIds.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Some product options were not found: [${missingIds.join(", ")}]` + ) + } + } + + const existingOptionsMap = new Map( + existingOptions.map((opt) => [opt.id, opt]) + ) + + const hydratedData = data.map((product) => { + if (!product.options?.length) return product + + const hydratedOptions = product.options.map((option) => { + if ("id" in option) { + const dbOption = existingOptionsMap.get(option.id) + if (!dbOption) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Product option with id ${option.id} not found.` + ) + } + + return { + id: dbOption.id, + title: dbOption.title, + values: dbOption.values?.map((v) => ({ value: v.value })), + } + } + return option + }) + + return { ...product, options: hydratedOptions } + }) + const normalizedProducts = this.normalizeCreateProductInput( - data, + hydratedData, sharedContext ) @@ -1738,7 +1791,12 @@ export default class ProductModuleService } if (product.options?.length) { - productOptionsToCreate.set(productId, product.options as any) + const newOptions = product.options.filter( + (o) => !("id" in o) + ) as CreateProductOptionDTO[] + if (newOptions.length) { + productOptionsToCreate.set(productId, newOptions) + } } if (product.variants?.length) { @@ -1748,9 +1806,9 @@ export default class ProductModuleService Object.entries(variant.options ?? {}).forEach(([key, value]) => { const productOption = product.options?.find( - (option) => option.title === key + (option) => (option as any).title === key )! - const productOptionValue = productOption.values?.find( + const productOptionValue = (productOption as any).values?.find( (optionValue) => (optionValue as any).value === value )! ;(productOptionValue as any).variants ??= [] @@ -1795,7 +1853,7 @@ export default class ProductModuleService const optionIds: string[] = [] for (const option of options) { - const optionId = generateEntityId((option as any).id, "opt") + const optionId = generateEntityId(undefined, "opt") optionIds.push(optionId) allOptionsWithIds.push({ ...option, @@ -1806,7 +1864,6 @@ export default class ProductModuleService productToOptionIdsMap.set(productId, optionIds) } - // Create products and options in parallel since IDs are pre-generated const [createdProducts] = await Promise.all([ this.productService_.create(productsToCreate, sharedContext), allOptionsWithIds.length > 0 @@ -1816,15 +1873,28 @@ export default class ProductModuleService const linkPairs: ProductTypes.ProductOptionProductPair[] = [] for (const product of createdProducts) { - const optionIds = productToOptionIdsMap.get(product.id) - if (optionIds) { - for (const optionId of optionIds) { - linkPairs.push({ - product_id: product.id, - product_option_id: optionId, - }) + const hydratedProduct = hydratedData.find( + (p) => p.title === product.title + ) + const allOptionIds: string[] = [] + + if (hydratedProduct?.options?.length) { + for (const option of hydratedProduct.options) { + if ("id" in option) { + allOptionIds.push(option.id) + } } } + + const newOptionIds = productToOptionIdsMap.get(product.id) ?? [] + const optionIds = [...new Set([...allOptionIds, ...newOptionIds])] + + for (const optionId of optionIds) { + linkPairs.push({ + product_id: product.id, + product_option_id: optionId, + }) + } } if (linkPairs.length > 0) { @@ -2021,7 +2091,7 @@ export default class ProductModuleService if (options?.length) { productData.variants?.forEach((variant) => { options.forEach((option) => { - if (!variant.options?.[option.title]) { + if (!variant.options?.[(option as any).title]) { missingOptionsVariants.push(variant.title) } }) @@ -2156,6 +2226,7 @@ export default class ProductModuleService ...(dbValue ? { id: dbValue.id } : {}), } }), + ...(option.id ? { id: option.id } : {}), ...(dbOption ? { id: dbOption.id } : {}), } }) From a97c04e77b18f719a0195a66cfae3f31891d16a3 Mon Sep 17 00:00:00 2001 From: willbouch Date: Fri, 31 Oct 2025 14:55:07 -0400 Subject: [PATCH 15/36] updates to link endpoint --- .../core/types/src/http/product/admin/payloads.ts | 11 +++++++++++ .../src/api/admin/products/[id]/options/route.ts | 8 +++++--- packages/medusa/src/api/admin/products/validators.ts | 1 - 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index 6fe99c2ec9c0d..3c322c3057429 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -613,3 +613,14 @@ export interface AdminImportProductsRequest { */ mime_type: string } + +export interface AdminLinkProductOptions { + /** + * The list of options to link to the product. + */ + add?: (string | AdminCreateProductOption)[] + /** + * The list of options to unlink to the product. + */ + remove?: string[] +} diff --git a/packages/medusa/src/api/admin/products/[id]/options/route.ts b/packages/medusa/src/api/admin/products/[id]/options/route.ts index 64d2fb8c8a394..61975ea0269a8 100644 --- a/packages/medusa/src/api/admin/products/[id]/options/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/options/route.ts @@ -7,7 +7,6 @@ import { import { HttpTypes } from "@medusajs/framework/types" import { remapKeysForProduct, remapProductResponse } from "../../helpers" import { linkProductOptionsToProductWorkflow } from "@medusajs/core-flows" -import { AdminLinkProductOptionsType } from "../../validators" export const GET = async ( req: AuthenticatedMedusaRequest, @@ -31,13 +30,16 @@ export const GET = async ( } export const POST = async ( - req: AuthenticatedMedusaRequest, + req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { const productId = req.params.id await linkProductOptionsToProductWorkflow(req.scope).run({ - input: req.validatedBody, + input: { + product_id: productId, + ...req.validatedBody, + }, }) const product = await refetchEntity({ diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index 77151a4bf2054..2cce2a01a8d6d 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -105,7 +105,6 @@ export type AdminLinkProductOptionsType = z.infer< typeof AdminLinkProductOptions > export const AdminLinkProductOptions = z.object({ - product_id: z.string(), add: z.array(z.union([z.string(), AdminCreateProductOption])).optional(), remove: z.array(z.string()).optional(), }) From f89f19e2e2e7f4bcf0494f952f218fe7fa99c324 Mon Sep 17 00:00:00 2001 From: William Bouchard <46496014+willbouch@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:07:13 -0500 Subject: [PATCH 16/36] Create forty-tables-fetch.md --- .changeset/forty-tables-fetch.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/forty-tables-fetch.md diff --git a/.changeset/forty-tables-fetch.md b/.changeset/forty-tables-fetch.md new file mode 100644 index 0000000000000..51de28445db3b --- /dev/null +++ b/.changeset/forty-tables-fetch.md @@ -0,0 +1,8 @@ +--- +"@medusajs/medusa": minor +"@medusajs/product": minor +"@medusajs/core-flows": minor +"@medusajs/types": minor +--- + +feat(medusa,product,core-flows,types): product options redesign (server-side) From f32e5d357c4e522bd47d294ec55b2952bf25ac88 Mon Sep 17 00:00:00 2001 From: willbouch Date: Mon, 3 Nov 2025 13:52:54 -0500 Subject: [PATCH 17/36] integration tests --- .../product-module-service/events.spec.ts | 35 +------- .../product-options.spec.ts | 89 ++++++++++++------- .../product-module-service/products.spec.ts | 3 - .../product/src/models/product-option.ts | 1 + .../modules/product/src/models/product.ts | 1 + .../src/services/product-module-service.ts | 10 ++- 6 files changed, 74 insertions(+), 65 deletions(-) diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts index 9c01a2cfc2de6..3b0ac17ea8bae 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts @@ -76,8 +76,9 @@ moduleIntegrationTestRunner({ // 3. Product option values created (5 values total: 3 sizes + 2 colors) // 4. Product variants created (2 variants) // 5. Product images created (2 images) + // 6. Product product options created (pivot table) (2 product-productOption links) - const expectedEventsCount = 1 + 2 + 5 + 2 + 2 // 12 total events + const expectedEventsCount = 1 + 2 + 5 + 2 + 2 + 2 // 14 total events expect(emittedEvents).toHaveLength(expectedEventsCount) // Verify product created event @@ -409,8 +410,8 @@ moduleIntegrationTestRunner({ expect(eventBusSpy).toHaveBeenCalledTimes(1) const emittedEvents = eventBusSpy.mock.calls[0][0] - // Total count should include: 1 product deleted + 1 variant deleted + 1 option deleted + 2 option values deleted + 2 images deleted = 7 events - expect(emittedEvents).toHaveLength(7) + // Total count should include: 1 product deleted + 1 variant deleted + 2 images deleted = 4 events + expect(emittedEvents).toHaveLength(4) // Should emit delete events for product and all its relations expect(emittedEvents).toEqual( @@ -436,34 +437,6 @@ moduleIntegrationTestRunner({ ]) ) - // Should emit delete events for options - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_DELETED, { - data: { id: createdProduct.options[0].id }, - object: "product_option", - source: Modules.PRODUCT, - action: CommonEvents.DELETED, - }), - ]) - ) - - // Should emit delete events for option values - createdProduct.options[0].values.forEach((value) => { - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_VALUE_DELETED, { - data: { - id: value.id, - }, - object: "product_option_value", - source: Modules.PRODUCT, - action: CommonEvents.DELETED, - }), - ]) - ) - }) - // Should emit delete events for images createdProduct.images.forEach((image) => { expect(emittedEvents).toEqual( diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/product-options.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/product-options.spec.ts index 84e5923675a00..457b95762339f 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/product-options.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/product-options.spec.ts @@ -6,6 +6,7 @@ import { } from "@medusajs/framework/utils" import { Product, ProductOption } from "@models" import { moduleIntegrationTestRunner } from "@medusajs/test-utils" +import { ProductProductOption } from "../../../src/models" jest.setTimeout(30000) @@ -20,6 +21,8 @@ moduleIntegrationTestRunner({ beforeEach(async () => { const testManager = await MikroOrmWrapper.forkManager() + + // Create products productOne = testManager.create(toMikroORMEntity(Product), { id: "product-1", title: "product 1", @@ -34,19 +37,45 @@ moduleIntegrationTestRunner({ status: ProductStatus.PUBLISHED, }) + // Create options (without linking products yet) optionOne = testManager.create(toMikroORMEntity(ProductOption), { id: "option-1", title: "option 1", - product: productOne, }) optionTwo = testManager.create(toMikroORMEntity(ProductOption), { id: "option-2", - title: "option 1", - product: productTwo, + title: "option 2", }) - await testManager.persistAndFlush([optionOne, optionTwo]) + // Create pivot entities to link products ↔ options + const productOptionOneLink = testManager.create( + toMikroORMEntity(ProductProductOption), + { + id: "prodopt-1", + product: productOne, + product_option: optionOne, + } + ) + + const productOptionTwoLink = testManager.create( + toMikroORMEntity(ProductProductOption), + { + id: "prodopt-2", + product: productTwo, + product_option: optionTwo, + } + ) + + // Persist everything + await testManager.persistAndFlush([ + productOne, + productTwo, + optionOne, + optionTwo, + productOptionOneLink, + productOptionTwoLink, + ]) }) describe("listOptions", () => { @@ -93,8 +122,8 @@ moduleIntegrationTestRunner({ id: optionOne.id, }, { - select: ["title", "product.id"], - relations: ["product"], + select: ["title", "products.id"], + relations: ["products"], take: 1, } ) @@ -103,10 +132,11 @@ moduleIntegrationTestRunner({ { id: optionOne.id, title: optionOne.title, - product_id: productOne.id, - product: { - id: productOne.id, - }, + products: [ + { + id: productOne.id, + } + ], }, ]) }) @@ -167,8 +197,8 @@ moduleIntegrationTestRunner({ id: optionOne.id, }, { - select: ["title", "product.id"], - relations: ["product"], + select: ["title", "products.id"], + relations: ["products"], take: 1, } ) @@ -178,10 +208,11 @@ moduleIntegrationTestRunner({ { id: optionOne.id, title: optionOne.title, - product_id: productOne.id, - product: { - id: productOne.id, - }, + products: [ + { + id: productOne.id, + } + ], }, ]) }) @@ -200,19 +231,20 @@ moduleIntegrationTestRunner({ it("should return requested attributes when requested through config", async () => { const option = await service.retrieveProductOption(optionOne.id, { - select: ["id", "product.handle", "product.title"], - relations: ["product"], + select: ["id", "products.handle", "products.title"], + relations: ["products"], }) expect(option).toEqual( expect.objectContaining({ id: optionOne.id, - product: { - id: "product-1", - handle: "product-1", - title: "product 1", - }, - product_id: "product-1", + products: [ + { + id: "product-1", + handle: "product-1", + title: "product 1", + } + ], }) ) }) @@ -283,7 +315,6 @@ moduleIntegrationTestRunner({ { title: "test", values: [], - product_id: productOne.id, }, ]) @@ -292,17 +323,15 @@ moduleIntegrationTestRunner({ title: "test", }, { - select: ["id", "title", "product.id"], - relations: ["product"], + select: ["id", "title", "products.id"], + relations: ["products"], } ) expect(productOption).toEqual( expect.objectContaining({ title: "test", - product: expect.objectContaining({ - id: productOne.id, - }), + products: [] }) ) }) diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index ed486a8d3d7f9..a02bc5b4d36d1 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -523,9 +523,6 @@ moduleIntegrationTestRunner({ ], }) - const beforeOption = productBefore.options.find( - (opt) => opt.title === "size" - )! expect(product.options).toHaveLength(3) expect(product.options).toEqual( expect.arrayContaining([ diff --git a/packages/modules/product/src/models/product-option.ts b/packages/modules/product/src/models/product-option.ts index c399b82dd24da..1ca35090964ef 100644 --- a/packages/modules/product/src/models/product-option.ts +++ b/packages/modules/product/src/models/product-option.ts @@ -15,6 +15,7 @@ const ProductOption = model }) .cascades({ delete: ["values"], + detach: ["products"], }) export default ProductOption diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index d702c139ea339..d27754b689b13 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -62,6 +62,7 @@ const Product = model }) .cascades({ delete: ["variants", "images"], + detach: ["options"], }) .indexes([ { diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index f64b561d5d841..abcbc43695ae2 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -1976,7 +1976,15 @@ export default class ProductModuleService await this.addProductOptionToProduct_(linkPairs, sharedContext) } - return createdProducts + const productIds = createdProducts.map((p) => p.id) + + const productsWithOptions = await this.productService_.list( + { id: productIds }, + { relations: ["options", "options.values"] }, + sharedContext + ) + + return productsWithOptions } @InjectTransactionManager() From 72245dba3948338cdf09ceeb13d69837e17f0ba3 Mon Sep 17 00:00:00 2001 From: willbouch Date: Tue, 4 Nov 2025 08:11:16 -0500 Subject: [PATCH 18/36] integration tests --- packages/core/types/src/product/common.ts | 4 +- .../product-module-service/products.spec.ts | 179 +++++++++++++----- .../product/src/repositories/product.ts | 6 +- .../src/services/product-module-service.ts | 170 +++++++++++------ 4 files changed, 248 insertions(+), 111 deletions(-) diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index 4af7ff1bc30a4..bf733c231e3eb 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -1674,9 +1674,9 @@ export interface UpdateProductDTO { */ category_ids?: string[] /** - * The associated options to create or update. + * The product options to associate with the product. */ - options?: UpsertProductOptionDTO[] + option_ids?: string[] /** * The product variants to be created and associated with the product. * You can also update existing product variants associated with the product. diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index a02bc5b4d36d1..108b6e2e08b41 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -223,7 +223,6 @@ moduleIntegrationTestRunner({ }, ...data.variants, ] - productBefore.options = data.options productBefore.images = data.images productBefore.thumbnail = data.thumbnail productBefore.tag_ids = data.tag_ids @@ -232,6 +231,8 @@ moduleIntegrationTestRunner({ productBefore.length = 201 productBefore.height = 301 productBefore.width = 401 + productBefore.option_ids = [productOne.options.map((o) => o.id)] + delete productBefore.options const updatedProducts = await service.upsertProducts([productBefore]) expect(updatedProducts).toHaveLength(1) @@ -280,11 +281,11 @@ moduleIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ id: expect.any(String), - title: productBefore.options?.[0].title, + title: productOne.options[0].title, values: expect.arrayContaining([ expect.objectContaining({ id: expect.any(String), - value: data.options[0].values[0], + value: productOne.options[0].values[0].value, }), ]), }), @@ -493,20 +494,7 @@ moduleIntegrationTestRunner({ { id: productBefore.id, title: "updated title", - options: [ - { - title: "size", - values: ["large", "small"], - }, - { - title: "color", - values: ["red"], - }, - { - title: "material", - values: ["cotton"], - }, - ], + option_ids: productBefore.options.map((o) => o.id), }, ]) @@ -523,33 +511,32 @@ moduleIntegrationTestRunner({ ], }) - expect(product.options).toHaveLength(3) + const beforeOptionOne = productBefore.options.find( + (opt) => opt.title === "size" + )! + const beforeOptionTwo = productBefore.options.find( + (opt) => opt.title === "color" + )! + expect(product.options).toHaveLength(2) expect(product.options).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: beforeOption.id, - title: beforeOption.title, + id: beforeOptionOne.id, + title: beforeOptionOne.title, values: expect.arrayContaining([ expect.objectContaining({ - id: beforeOption.values[0].id, - value: beforeOption.values[0].value, + id: beforeOptionOne.values[0].id, + value: beforeOptionOne.values[0].value, }), ]), }), expect.objectContaining({ - title: "color", + id: beforeOptionTwo.id, + title: beforeOptionTwo.title, values: expect.arrayContaining([ expect.objectContaining({ - value: "red", - }), - ]), - }), - expect.objectContaining({ - id: expect.any(String), - title: "material", - values: expect.arrayContaining([ - expect.objectContaining({ - value: "cotton", + id: beforeOptionTwo.values[0].id, + value: beforeOptionTwo.values[0].value, }), ]), }), @@ -807,9 +794,15 @@ moduleIntegrationTestRunner({ }) it("should simultaneously update options and variants", async () => { + const option = ( + await service.createProductOptions([ + { title: "material", values: ["cotton", "silk"] }, + ]) + )[0] + const updateData = { id: productTwo.id, - options: [{ title: "material", values: ["cotton", "silk"] }], + option_ids: [...productTwo.options.map((o) => o.id), option.id], variants: [{ title: "variant 1", options: { material: "cotton" } }], } @@ -1159,6 +1152,111 @@ moduleIntegrationTestRunner({ await service.softDeleteProducts([products[0].id]) + const deletedProducts = await service.listProducts( + { id: products[0].id }, + { + relations: ["variants"], + withDeleted: true, + } + ) + + expect(deletedProducts).toHaveLength(1) + expect(deletedProducts[0].deleted_at).not.toBeNull() + + for (const variant of deletedProducts[0].variants) { + expect(variant.deleted_at).not.toBeNull() + } + }) + + it("should not soft delete a product's options and option values", async () => { + const data = buildProductAndRelationsData({ + images, + thumbnail: images[0].url, + options: [ + { title: "size", values: ["large", "small"] }, + { title: "color", values: ["red", "blue"] }, + { title: "material", values: ["cotton", "polyester"] }, + ], + variants: [ + { + title: "Large Red Cotton", + sku: "LRG-RED-CTN", + options: { + size: "large", + color: "red", + material: "cotton", + }, + }, + { + title: "Large Red Polyester", + sku: "LRG-RED-PLY", + options: { + size: "large", + color: "red", + material: "polyester", + }, + }, + { + title: "Large Blue Cotton", + sku: "LRG-BLU-CTN", + options: { + size: "large", + color: "blue", + material: "cotton", + }, + }, + { + title: "Large Blue Polyester", + sku: "LRG-BLU-PLY", + options: { + size: "large", + color: "blue", + material: "polyester", + }, + }, + { + title: "Small Red Cotton", + sku: "SML-RED-CTN", + options: { + size: "small", + color: "red", + material: "cotton", + }, + }, + { + title: "Small Red Polyester", + sku: "SML-RED-PLY", + options: { + size: "small", + color: "red", + material: "polyester", + }, + }, + { + title: "Small Blue Cotton", + sku: "SML-BLU-CTN", + options: { + size: "small", + color: "blue", + material: "cotton", + }, + }, + { + title: "Small Blue Polyester", + sku: "SML-BLU-PLY", + options: { + size: "small", + color: "blue", + material: "polyester", + }, + }, + ], + }) + + const products = await service.createProducts([data]) + + await service.softDeleteProducts([products[0].id]) + const deletedProducts = await service.listProducts( { id: products[0].id }, { @@ -1172,11 +1270,8 @@ moduleIntegrationTestRunner({ } ) - expect(deletedProducts).toHaveLength(1) - expect(deletedProducts[0].deleted_at).not.toBeNull() - for (const option of deletedProducts[0].options) { - expect(option.deleted_at).not.toBeNull() + expect(option.deleted_at).toBeNull() } const productOptionsValues = deletedProducts[0].options @@ -1184,11 +1279,7 @@ moduleIntegrationTestRunner({ .flat() for (const optionValue of productOptionsValues) { - expect(optionValue.deleted_at).not.toBeNull() - } - - for (const variant of deletedProducts[0].variants) { - expect(variant.deleted_at).not.toBeNull() + expect(optionValue.deleted_at).toBeNull() } const variantsOptions = deletedProducts[0].options @@ -1196,7 +1287,7 @@ moduleIntegrationTestRunner({ .flat() for (const option of variantsOptions) { - expect(option.deleted_at).not.toBeNull() + expect(option.deleted_at).toBeNull() } }) diff --git a/packages/modules/product/src/repositories/product.ts b/packages/modules/product/src/repositories/product.ts index fff62ada8b0b2..92208ae6f10f7 100644 --- a/packages/modules/product/src/repositories/product.ts +++ b/packages/modules/product/src/repositories/product.ts @@ -5,11 +5,11 @@ import { arrayDifference, buildQuery, DALUtils, - MedusaError, + deepCopy, + isDefined, isPresent, + MedusaError, mergeMetadata, - isDefined, - deepCopy, } from "@medusajs/framework/utils" import { SqlEntityManager, diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index abcbc43695ae2..8099f590be8c4 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -1980,7 +1980,16 @@ export default class ProductModuleService const productsWithOptions = await this.productService_.list( { id: productIds }, - { relations: ["options", "options.values"] }, + { + relations: [ + "options", + "options.values", + "options.products", + "variants", + "images", + "tags", + ], + }, sharedContext ) @@ -2007,27 +2016,89 @@ export default class ProductModuleService .registerSubscriber(new subscriber(sharedContext)) } - const originalProducts = await this.productService_.list( - { - id: data.map((d) => d.id), - }, - { - relations: [ - "options", - "options.values", - "options.products", - "variants", - "images", - "tags", - ], - }, - sharedContext - ) + const allOptionIds = data + .flatMap((p) => p.option_ids ?? []) + .filter((id) => !!id) - const normalizedProducts = this.normalizeUpdateProductInput( - data, - originalProducts - ) + const [originalProducts, existingOptions] = await Promise.all([ + this.productService_.list( + { id: data.map((d) => d.id) }, + { + relations: [ + "options", + "options.values", + "options.products", + "variants", + "images", + "tags", + ], + }, + sharedContext + ), + allOptionIds.length + ? this.productOptionService_.list( + { id: allOptionIds }, + { + relations: ["values", "products"], + }, + sharedContext + ) + : Promise.resolve([]), + ]) + + if (allOptionIds.length && existingOptions.length !== allOptionIds.length) { + const found = new Set(existingOptions.map((opt) => opt.id)) + const missing = allOptionIds.filter((id) => !found.has(id)) + if (missing.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Some product options were not found: [${missing.join(", ")}]` + ) + } + } + + const linkPairs: ProductTypes.ProductOptionProductPair[] = [] + const unlinkPairs: ProductTypes.ProductOptionProductPair[] = [] + + for (const product of data) { + const input = data.find((p) => p.id === product.id)! + const newOptionIds = new Set(input.option_ids ?? []) + + const existingOptionIds = new Set( + originalProducts + .find((p) => p.id === product.id) + ?.options?.map((o) => o.id) ?? [] + ) + + for (const optionId of newOptionIds) { + if (!existingOptionIds.has(optionId)) { + linkPairs.push({ + product_id: product.id, + product_option_id: optionId, + }) + } + } + + for (const optionId of existingOptionIds) { + if (!newOptionIds.has(optionId)) { + unlinkPairs.push({ + product_id: product.id, + product_option_id: optionId, + }) + } + } + + delete product.option_ids + } + + await Promise.all([ + linkPairs.length && + this.addProductOptionToProduct_(linkPairs, sharedContext), + unlinkPairs.length && + this.removeProductOptionFromProduct_(unlinkPairs, sharedContext), + ]) + + const normalizedProducts = this.normalizeUpdateProductInput(data) for (const product of normalizedProducts) { this.validateProductUpdatePayload(product) @@ -2063,7 +2134,7 @@ export default class ProductModuleService ): Promise< ProductTypes.ProductOptionValueDTO | ProductTypes.ProductOptionValueDTO[] > { - // TODO: There is a missmatch in the API which lead to function with different number of + // TODO: There is a mismatch in the API which lead to function with different number of // arguments. Therefore, applying the MedusaContext() decorator to the function will not work // because the context arg index will differ from method to method. sharedContext.messageAggregator ??= new MessageAggregator() @@ -2210,6 +2281,20 @@ export default class ProductModuleService ) as ProductTypes.CreateProductDTO[] for (const productData of normalizedProducts) { + if (productData.options?.length) { + ;(productData as any).options = productData.options?.map((option) => { + return { + title: (option as any).title, + values: (option as any).values?.map((value) => { + return { + value: value, + } + }), + ...((option as any).id ? { id: (option as any).id } : {}), + } + }) + } + if (!productData.handle && productData.title) { productData.handle = toHandle(productData.title) } @@ -2265,25 +2350,8 @@ export default class ProductModuleService TOutput = T extends UpdateProductInput[] ? UpdateProductInput[] : UpdateProductInput - >( - products: T, - originalProducts?: InferEntityType[] - ): TOutput { + >(products: T): TOutput { const products_ = Array.isArray(products) ? products : [products] - const productsIds = products_.map((p) => p.id).filter(Boolean) - - let dbOptions: InferEntityType[] = [] - - if (productsIds.length) { - // Re map options to handle non serialized data as well - dbOptions = - originalProducts - ?.map((originalProduct) => - originalProduct.options.map((option) => option) - ) - .flat() - .filter(Boolean) ?? [] - } const normalizedProducts: UpdateProductInput[] = [] @@ -2293,28 +2361,6 @@ export default class ProductModuleService productData.discountable = false } - if (productData.options?.length) { - ;(productData as any).options = productData.options?.map((option) => { - const dbOption = dbOptions.find( - (o) => o.title === option.title || o.id === option.id - ) - return { - title: option.title, - values: option.values?.map((value) => { - const dbValue = dbOption?.values?.find( - (val) => val.value === value - ) - return { - value: value, - ...(dbValue ? { id: dbValue.id } : {}), - } - }), - ...(option.id ? { id: option.id } : {}), - ...(dbOption ? { id: dbOption.id } : {}), - } - }) - } - if (productData.tag_ids) { ;(productData as any).tags = productData.tag_ids.map((tid) => ({ id: tid, From c35bcd28324edb4ae6dc60ecbd123b8c98ccea37 Mon Sep 17 00:00:00 2001 From: willbouch Date: Tue, 4 Nov 2025 11:13:24 -0500 Subject: [PATCH 19/36] product integration tests --- packages/core/utils/src/product/events.ts | 2 + .../product-module-service/events.spec.ts | 88 +++++-------------- .../product-module-service/products.spec.ts | 2 +- .../product/src/repositories/product.ts | 12 +-- .../src/services/product-module-service.ts | 10 ++- 5 files changed, 36 insertions(+), 78 deletions(-) diff --git a/packages/core/utils/src/product/events.ts b/packages/core/utils/src/product/events.ts index 64c1ff2a397f5..724ceff75bd81 100644 --- a/packages/core/utils/src/product/events.ts +++ b/packages/core/utils/src/product/events.ts @@ -5,6 +5,7 @@ const eventBaseNames: [ "product", "productVariant", "productOption", + "productProductOption", "productOptionValue", "productType", "productTag", @@ -15,6 +16,7 @@ const eventBaseNames: [ "product", "productVariant", "productOption", + "productProductOption", "productOptionValue", "productType", "productTag", diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts index 3b0ac17ea8bae..d7e6093d7fecc 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts @@ -184,6 +184,16 @@ moduleIntegrationTestRunner({ }) it("should emit cascade events when updating product with relations", async () => { + const newOption = ( + await service.createProductOptions([ + { + title: "new-size-option", + values: ["small", "large"], + }, + ]) + )[0] + eventBusSpy.mockClear() + const existingOption = existingProduct.options.find( (option: any) => option.title === "existing-option" )! as InferEntityType @@ -197,31 +207,21 @@ moduleIntegrationTestRunner({ id: existingProduct.id, title: "Updated Product", images: [{ url: "new-image-1.jpg" }, { url: "new-image-2.jpg" }], - options: [ - { - title: "new-size-option", - values: ["small", "large"], - }, - { - id: existingOption.id, - title: "updated-existing-option", - values: ["value-1"], - }, - ], + option_ids: [newOption.id, existingOption.id], variants: [ { id: existingVariant.id, title: "updated-existing-variant", options: { "new-size-option": "small", - "updated-existing-option": "value-1", + "existing-option": "value-1", }, }, { title: "New Variant", options: { "new-size-option": "large", - "updated-existing-option": "value-1", + "existing-option": "value-1", }, }, ], @@ -244,8 +244,8 @@ moduleIntegrationTestRunner({ expect(eventBusSpy).toHaveBeenCalledTimes(1) const emittedEvents = eventBusSpy.mock.calls[0][0] - // Total count should include: 1 product update + 1 option created + 2 option values created + 1 option update + 1 option deleted + 1 option value deleted + 1 variant created + 1 variant updated + 2 images created + 1 image deleted = 12 events - expect(emittedEvents).toHaveLength(12) + // Total count should include: 1 product update + 1 option linked + 1 option unlinked + 1 variant created + 1 variant updated + 2 images created + 1 image deleted = 8 events + expect(emittedEvents).toHaveLength(8) // Should emit product update event expect(emittedEvents).toEqual( @@ -259,68 +259,24 @@ moduleIntegrationTestRunner({ ]) ) - // Should emit option created event for new option + // Should emit option link event for new option expect(emittedEvents).toEqual( expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_CREATED, { + composeMessage(ProductEvents.PRODUCT_PRODUCT_OPTION_CREATED, { data: expect.objectContaining({ id: expect.any(String) }), - object: "product_option", + object: "product_product_option", source: Modules.PRODUCT, action: CommonEvents.CREATED, }), ]) ) - // Should emit option value created events for new option values - const newOptionValues = updatedProduct.options.find( - (option) => option.title === "new-size-option" - )!.values - - newOptionValues.forEach((value) => { - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_VALUE_CREATED, { - data: expect.objectContaining({ id: value.id }), - object: "product_option_value", - source: Modules.PRODUCT, - action: CommonEvents.CREATED, - }), - ]) - ) - }) - - // should emit option updated event for updated option - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_UPDATED, { - data: expect.objectContaining({ id: existingOption.id }), - object: "product_option", - source: Modules.PRODUCT, - action: CommonEvents.UPDATED, - }), - ]) - ) - - // Should emit option deleted event for deleted option + // Should emit option unlink event for new option expect(emittedEvents).toEqual( expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_DELETED, { - data: expect.objectContaining({ id: expectedDeletedOption.id }), - object: "product_option", - source: Modules.PRODUCT, - action: CommonEvents.DELETED, - }), - ]) - ) - - // Should emit option value event for deleted option value - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_VALUE_DELETED, { - data: expect.objectContaining({ - id: expectedDeletedOption.values[0].id, - }), - object: "product_option_value", + composeMessage(ProductEvents.PRODUCT_PRODUCT_OPTION_DELETED, { + data: expect.objectContaining({ id: expect.any(String) }), + object: "product_product_option", source: Modules.PRODUCT, action: CommonEvents.DELETED, }), diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index 108b6e2e08b41..111982fa2ee89 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -802,7 +802,7 @@ moduleIntegrationTestRunner({ const updateData = { id: productTwo.id, - option_ids: [...productTwo.options.map((o) => o.id), option.id], + option_ids: [option.id], variants: [{ title: "variant 1", options: { material: "cotton" } }], } diff --git a/packages/modules/product/src/repositories/product.ts b/packages/modules/product/src/repositories/product.ts index 92208ae6f10f7..70cbfa792cd1d 100644 --- a/packages/modules/product/src/repositories/product.ts +++ b/packages/modules/product/src/repositories/product.ts @@ -3,7 +3,6 @@ import { Product, ProductOption } from "@models" import { Context, DAL, InferEntityType } from "@medusajs/framework/types" import { arrayDifference, - buildQuery, DALUtils, deepCopy, isDefined, @@ -95,15 +94,12 @@ export class ProductRepository extends DALUtils.mikroOrmBaseRepositoryFactory( const relationsToLoad = ProductRepository.#getProductDeepUpdateRelationsToLoad(productsToUpdate_) - const findOptions = buildQuery( + const manager = super.getActiveManager(context) + const products = await manager.find>( + Product.name, { id: productIdsToUpdate }, - { - relations: relationsToLoad, - take: productsToUpdate_.length, - } + { populate: relationsToLoad, limit: productsToUpdate_.length } as any ) - - const products = await this.find(findOptions, context) const productsMap = new Map(products.map((p) => [p.id, p])) const productIds = Array.from(productsMap.keys()) diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 8099f590be8c4..df33f78763758 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -2059,10 +2059,12 @@ export default class ProductModuleService const linkPairs: ProductTypes.ProductOptionProductPair[] = [] const unlinkPairs: ProductTypes.ProductOptionProductPair[] = [] - for (const product of data) { - const input = data.find((p) => p.id === product.id)! - const newOptionIds = new Set(input.option_ids ?? []) + if (!product.option_ids) { + continue + } + + const newOptionIds = new Set(product.option_ids) const existingOptionIds = new Set( originalProducts @@ -2098,6 +2100,8 @@ export default class ProductModuleService this.removeProductOptionFromProduct_(unlinkPairs, sharedContext), ]) + await (sharedContext.transactionManager as any).flush() + const normalizedProducts = this.normalizeUpdateProductInput(data) for (const product of normalizedProducts) { From deff5ccdaa9e0c6795fafddbf3cb97b04795d1a9 Mon Sep 17 00:00:00 2001 From: willbouch Date: Tue, 4 Nov 2025 11:56:55 -0500 Subject: [PATCH 20/36] update validator --- packages/medusa/src/api/admin/products/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index e6682f59d08e7..b18f42988fb65 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -265,7 +265,7 @@ export const UpdateProduct = z title: z.string().optional(), discountable: booleanString().optional(), is_giftcard: booleanString().optional(), - options: z.array(UpdateProductOption).optional(), + option_ids: z.array(IdAssociation).optional(), variants: z.array(UpdateProductVariant).optional(), status: statusEnum.optional(), subtitle: z.string().nullish(), From c914fc18dc436dbddf573eadde66068b580264b0 Mon Sep 17 00:00:00 2001 From: willbouch Date: Wed, 5 Nov 2025 09:46:57 -0500 Subject: [PATCH 21/36] fix import --- .../product/admin/product-imports.spec.ts | 2 +- .../core-flows/src/product/steps/index.ts | 1 + .../process-product-options-for-import.ts | 78 +++++++++++++++++++ .../src/product/workflows/batch-products.ts | 48 +++++++----- .../types/src/http/product/admin/payloads.ts | 4 +- 5 files changed, 110 insertions(+), 23 deletions(-) create mode 100644 packages/core/core-flows/src/product/steps/process-product-options-for-import.ts diff --git a/integration-tests/http/__tests__/product/admin/product-imports.spec.ts b/integration-tests/http/__tests__/product/admin/product-imports.spec.ts index ed534704352e8..fdf6a4baad51d 100644 --- a/integration-tests/http/__tests__/product/admin/product-imports.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product-imports.spec.ts @@ -4,8 +4,8 @@ import { csv2json, json2csv } from "json-2-csv" import { CommonEvents, Modules } from "@medusajs/utils" import { IEventBusModuleService, IFileModuleService } from "@medusajs/types" import { - TestEventUtils, medusaIntegrationTestRunner, + TestEventUtils, } from "@medusajs/test-utils" import { adminHeaders, diff --git a/packages/core/core-flows/src/product/steps/index.ts b/packages/core/core-flows/src/product/steps/index.ts index a8cc947299326..4913eb5ee231b 100644 --- a/packages/core/core-flows/src/product/steps/index.ts +++ b/packages/core/core-flows/src/product/steps/index.ts @@ -33,3 +33,4 @@ export * from "./normalize-products" export * from "./normalize-products-to-chunks" export * from "./process-import-chunks" export * from "./link-product-options-to-product" +export * from "./process-product-options-for-import" diff --git a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts new file mode 100644 index 0000000000000..6b999be9aec00 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts @@ -0,0 +1,78 @@ +import type { + IProductModuleService, + ProductTypes, + UpdateProductWorkflowInputDTO, +} from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { deepCopy } from "@medusajs/utils" + +export const processProductOptionsForImportStepId = + "process-product-options-for-import" + +export type ProcessProductOptionsForImportInput = { + products: (Omit & { + options: ProductTypes.CreateProductOptionDTO[] + })[] +} + +/** + * This step processes products with options during import: + * 1. Creates product options + * 2. Transforms product.options to product.option_ids + * 3. Transforms variant options from {title: value} to {optionId: value} + */ +export const processProductOptionsForImportStep = createStep( + processProductOptionsForImportStepId, + async ( + data: ProcessProductOptionsForImportInput, + { container } + ): Promise> => { + const productService = container.resolve( + Modules.PRODUCT + ) + + const createdOptionIds: string[] = [] + const processedProducts: UpdateProductWorkflowInputDTO[] = [] + + for (const product of data.products) { + if (product.options && product.options.length > 0) { + // Create the product options + const createdOptions = await productService.createProductOptions( + product.options + ) + + createdOptionIds.push(...createdOptions.map((opt) => opt.id)) + + // Build a map of option title to option ID + const optionTitleToIdMap = new Map() + createdOptions.forEach((option) => { + optionTitleToIdMap.set(option.title, option.id) + }) + + // Transform product to use option_ids instead of options + const transformedProduct: any = deepCopy(product) + delete transformedProduct.options + transformedProduct.option_ids = createdOptions.map((opt) => opt.id) + + processedProducts.push(transformedProduct) + } else { + processedProducts.push(product) + } + } + + return new StepResponse(processedProducts, createdOptionIds) + }, + async (createdOptionIds, { container }) => { + if (!createdOptionIds || createdOptionIds.length === 0) { + return + } + + const productService = container.resolve( + Modules.PRODUCT + ) + + // Delete created options (compensation) + await productService.deleteProductOptions(createdOptionIds) + } +) diff --git a/packages/core/core-flows/src/product/workflows/batch-products.ts b/packages/core/core-flows/src/product/workflows/batch-products.ts index fea00eb5483e5..bbe9982c5985b 100644 --- a/packages/core/core-flows/src/product/workflows/batch-products.ts +++ b/packages/core/core-flows/src/product/workflows/batch-products.ts @@ -6,16 +6,17 @@ import { UpdateProductWorkflowInputDTO, } from "@medusajs/framework/types" import { - WorkflowData, - WorkflowResponse, createWorkflow, parallelize, transform, when, + WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { createProductsWorkflow } from "./create-products" import { deleteProductsWorkflow } from "./delete-products" import { updateProductsWorkflow } from "./update-products" +import { processProductOptionsForImportStep } from "../steps" /** * The products to manage. @@ -26,21 +27,6 @@ export interface BatchProductWorkflowInput UpdateProductWorkflowInputDTO > {} -const conditionallyCreateProducts = (input: BatchProductWorkflowInput) => - when({ input }, ({ input }) => !!input.create?.length).then(() => - createProductsWorkflow.runAsStep({ input: { products: input.create! } }) - ) - -const conditionallyUpdateProducts = (input: BatchProductWorkflowInput) => - when({ input }, ({ input }) => !!input.update?.length).then(() => - updateProductsWorkflow.runAsStep({ input: { products: input.update! } }) - ) - -const conditionallyDeleteProducts = (input: BatchProductWorkflowInput) => - when({ input }, ({ input }) => !!input.delete?.length).then(() => - deleteProductsWorkflow.runAsStep({ input: { ids: input.delete! } }) - ) - export const batchProductsWorkflowId = "batch-products" /** * This workflow creates, updates, or deletes products. It's used by the @@ -97,10 +83,32 @@ export const batchProductsWorkflow = createWorkflow( ( input: WorkflowData ): WorkflowResponse> => { + const productsToUpdate = transform({ input }, ({ input }) => { + return input.update ?? [] + }) + + const processedProductsToUpdate = processProductOptionsForImportStep({ + products: productsToUpdate as unknown as (Omit< + UpdateProductWorkflowInputDTO, + "option_ids" + > & { options: ProductTypes.CreateProductOptionDTO[] })[], + }) + const res = parallelize( - conditionallyCreateProducts(input), - conditionallyUpdateProducts(input), - conditionallyDeleteProducts(input) + when({ input }, ({ input }) => !!input.create?.length).then(() => + createProductsWorkflow.runAsStep({ input: { products: input.create! } }) + ), + when( + { processedProductsToUpdate }, + ({ processedProductsToUpdate }) => !!processedProductsToUpdate.length + ).then(() => + updateProductsWorkflow.runAsStep({ + input: { products: processedProductsToUpdate }, + }) + ), + when({ input }, ({ input }) => !!input.delete?.length).then(() => + deleteProductsWorkflow.runAsStep({ input: { ids: input.delete! } }) + ) ) return new WorkflowResponse( diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index b1525f4e2c97e..3f55fbefec3b1 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -457,9 +457,9 @@ export interface AdminUpdateProduct { id: string }[] /** - * The product's options. + * The IDs of the associated product options. */ - options?: AdminUpdateProductOption[] + option_ids?: string[] /** * The product's variants. */ From 4e0d98bbe4335c6941db05e7a83eeeabd44ce64a Mon Sep 17 00:00:00 2001 From: willbouch Date: Wed, 5 Nov 2025 10:15:16 -0500 Subject: [PATCH 22/36] fix import --- .../core/core-flows/src/product/workflows/batch-products.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/core-flows/src/product/workflows/batch-products.ts b/packages/core/core-flows/src/product/workflows/batch-products.ts index bbe9982c5985b..790e91f50bf93 100644 --- a/packages/core/core-flows/src/product/workflows/batch-products.ts +++ b/packages/core/core-flows/src/product/workflows/batch-products.ts @@ -16,7 +16,7 @@ import { import { createProductsWorkflow } from "./create-products" import { deleteProductsWorkflow } from "./delete-products" import { updateProductsWorkflow } from "./update-products" -import { processProductOptionsForImportStep } from "../steps" +import { processProductOptionsForImportStep } from "../steps/process-product-options-for-import" /** * The products to manage. From afb4455a1ceff6d1898e968d8af0dc11b16b368d Mon Sep 17 00:00:00 2001 From: willbouch Date: Wed, 5 Nov 2025 10:54:05 -0500 Subject: [PATCH 23/36] maintain ordering --- .../product/src/services/product-module-service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index df33f78763758..9a38fef1339b7 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -1993,7 +1993,14 @@ export default class ProductModuleService sharedContext ) - return productsWithOptions + const productIdOrder = new Map(productIds.map((id, index) => [id, index])) + + const orderedProductsWithOptions = [...productsWithOptions].sort( + (a, b) => + (productIdOrder.get(a.id) ?? 0) - (productIdOrder.get(b.id) ?? 0) + ) + + return orderedProductsWithOptions } @InjectTransactionManager() From 5ffe5f60febce2ec5abffcabbedb8c385db10133 Mon Sep 17 00:00:00 2001 From: willbouch Date: Wed, 5 Nov 2025 11:29:23 -0500 Subject: [PATCH 24/36] self review --- .../src/product/steps/process-product-options-for-import.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts index 6b999be9aec00..543cae0436d69 100644 --- a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts +++ b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts @@ -37,14 +37,12 @@ export const processProductOptionsForImportStep = createStep( for (const product of data.products) { if (product.options && product.options.length > 0) { - // Create the product options const createdOptions = await productService.createProductOptions( product.options ) createdOptionIds.push(...createdOptions.map((opt) => opt.id)) - // Build a map of option title to option ID const optionTitleToIdMap = new Map() createdOptions.forEach((option) => { optionTitleToIdMap.set(option.title, option.id) @@ -72,7 +70,6 @@ export const processProductOptionsForImportStep = createStep( Modules.PRODUCT ) - // Delete created options (compensation) await productService.deleteProductOptions(createdOptionIds) } ) From 2c581732f9c877017173845ddc6b52f7c1866349 Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 6 Nov 2025 07:51:02 -0500 Subject: [PATCH 25/36] pr comments --- .../product-option/product-option.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/integration-tests/http/__tests__/product-option/product-option.spec.ts b/integration-tests/http/__tests__/product-option/product-option.spec.ts index d051d6657d635..9987a208184b1 100644 --- a/integration-tests/http/__tests__/product-option/product-option.spec.ts +++ b/integration-tests/http/__tests__/product-option/product-option.spec.ts @@ -41,7 +41,7 @@ medusaIntegrationTestRunner({ }) describe("GET /admin/product-options", () => { - it("returns a list of product options", async () => { + it("should return a list of product options", async () => { const res = await api.get("/admin/product-options", adminHeaders) expect(res.status).toEqual(200) @@ -68,7 +68,7 @@ medusaIntegrationTestRunner({ ) }) - it("returns a list of product options matching free text search param", async () => { + it("should return a list of product options matching free text search param", async () => { const res = await api.get("/admin/product-options?q=1", adminHeaders) expect(res.status).toEqual(200) @@ -80,7 +80,7 @@ medusaIntegrationTestRunner({ ) }) - it("returns a list of exclusive product options", async () => { + it("should return a list of exclusive product options", async () => { const res = await api.get( "/admin/product-options?is_exclusive=false", adminHeaders @@ -97,7 +97,7 @@ medusaIntegrationTestRunner({ }) describe("POST /admin/product-options", () => { - it("creates a product option with value ranks", async () => { + it("should create a product option with value ranks", async () => { const option = ( await api.post( `/admin/product-options`, @@ -131,7 +131,7 @@ medusaIntegrationTestRunner({ ) }) - it("throws ir a rank is specified for invalid value", async () => { + it("should throw if a rank is specified for invalid value", async () => { const error = await api .post( `/admin/product-options`, @@ -155,7 +155,7 @@ medusaIntegrationTestRunner({ }) describe("GET /admin/product-options/[id]", () => { - it("returns a product option", async () => { + it("should return a product option", async () => { const res = await api.get( `/admin/product-options/${option1.id}`, adminHeaders @@ -177,7 +177,7 @@ medusaIntegrationTestRunner({ }) describe("POST /admin/product-options/[id]", () => { - it("updates a product option", async () => { + it("should update a product option", async () => { const option = ( await api.post( `/admin/product-options/${option2.id}`, @@ -208,7 +208,7 @@ medusaIntegrationTestRunner({ expect(res.data.product_options.length).toEqual(0) }) - it("updates a product value ranks", async () => { + it("should update a product value ranks", async () => { const option = ( await api.post( `/admin/product-options/${option2.id}`, @@ -242,7 +242,7 @@ medusaIntegrationTestRunner({ }) describe("DELETE /admin/product-options/[id]", () => { - it("deletes a product option", async () => { + it("should delete a product option", async () => { await api.delete(`/admin/product-options/${option2.id}`, adminHeaders) const res = await api.get("/admin/product-options", adminHeaders) From 3a9d74e334c97dc579aac0f6a5ebfa904bf6315b Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 6 Nov 2025 07:54:34 -0500 Subject: [PATCH 26/36] pr comments --- .../http/__tests__/product-option/product-option.spec.ts | 3 +++ .../src/product/steps/process-product-options-for-import.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/integration-tests/http/__tests__/product-option/product-option.spec.ts b/integration-tests/http/__tests__/product-option/product-option.spec.ts index 9987a208184b1..0ee8aecd2480c 100644 --- a/integration-tests/http/__tests__/product-option/product-option.spec.ts +++ b/integration-tests/http/__tests__/product-option/product-option.spec.ts @@ -162,6 +162,7 @@ medusaIntegrationTestRunner({ ) expect(res.status).toEqual(200) + expect(res.data.product_option.values.length).toEqual(3) expect(res.data.product_option).toEqual( expect.objectContaining({ title: "option1", @@ -188,6 +189,7 @@ medusaIntegrationTestRunner({ ) ).data.product_option + expect(option.values.length).toEqual(2) expect(option).toEqual( expect.objectContaining({ title: "option2", @@ -222,6 +224,7 @@ medusaIntegrationTestRunner({ ) ).data.product_option + expect(option.values.length).toEqual(2) expect(option).toEqual( expect.objectContaining({ title: "option2", diff --git a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts index 543cae0436d69..c4a17afbc1d49 100644 --- a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts +++ b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts @@ -36,7 +36,7 @@ export const processProductOptionsForImportStep = createStep( const processedProducts: UpdateProductWorkflowInputDTO[] = [] for (const product of data.products) { - if (product.options && product.options.length > 0) { + if (product.options?.length) { const createdOptions = await productService.createProductOptions( product.options ) From 39289af734d2224a073569481f77f01e33aa503e Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 6 Nov 2025 07:58:39 -0500 Subject: [PATCH 27/36] pr comments --- .../src/product/workflows/link-product-options-to-product.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts b/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts index c90017d42e447..8362b0b3e3084 100644 --- a/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts +++ b/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts @@ -10,6 +10,7 @@ import { createProductOptionsStep, linkProductOptionsToProductStep, } from "../steps" +import { isString } from "@medusajs/framework/utils" /** * The data to add/remove one or more product options to/from a product. @@ -61,7 +62,7 @@ export const linkProductOptionsToProductWorkflow = createWorkflow( linkProductOptionsToProductWorkflowId, (input: WorkflowData) => { const optionsToCreate = transform({ input }, ({ input }) => { - return (input.add ?? []).filter((option) => !(typeof option === "string")) + return (input.add ?? []).filter((option) => !isString(option)) }) as ProductTypes.CreateProductOptionDTO[] const createdIds = when( @@ -80,7 +81,7 @@ export const linkProductOptionsToProductWorkflow = createWorkflow( { input, createdIds }, ({ input, createdIds }) => { return (input.add ?? []) - .filter((option) => typeof option === "string") + .filter((option) => isString(option)) .concat(createdIds ? createdIds : []) } ) From 75662f4eb4540d7a185e98ca57fbac6a007c8f2e Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 6 Nov 2025 08:05:06 -0500 Subject: [PATCH 28/36] is string --- .../product/src/services/product-module-service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 9a38fef1339b7..0ac78d5c983e0 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -902,9 +902,9 @@ export default class ProductModuleService ...opt, values: opt.values?.map((v) => { // Normalize each value into an object and attach rank if available - const valueObj = typeof v === "string" ? { value: v } : v + const valueObj = isString(v) ? { value: v } : v const rank = - opt.ranks && typeof v === "string" + opt.ranks && isString(v) ? opt.ranks[v] : opt.ranks?.[valueObj.value] @@ -1057,10 +1057,10 @@ export default class ProductModuleService if (opt.values) { // If new values are provided → normalize and apply ranks normalizedValues = opt.values.map((v) => { - const valueObj = typeof v === "string" ? { value: v } : v + const valueObj = isString(v) ? { value: v } : v const rank = - opt.ranks && typeof v === "string" + opt.ranks && isString(v) ? opt.ranks[v] : opt.ranks?.[valueObj.value] From 1eeb65d1415fb1ea4a44dc78328617fe60a1876c Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 6 Nov 2025 08:45:51 -0500 Subject: [PATCH 29/36] pr comments --- .../__tests__/product/admin/product.spec.ts | 139 ++++++++++++++++++ .../link-product-options-to-product.ts | 33 +++-- .../admin/product-categories/middlewares.ts | 1 - .../api/admin/product-options/middlewares.ts | 1 - .../src/api/admin/product-tags/middlewares.ts | 6 +- 5 files changed, 157 insertions(+), 23 deletions(-) diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 0c20a45f619b3..66188a871e847 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -2815,6 +2815,145 @@ medusaIntegrationTestRunner({ }) }) + describe("POST /admin/products/:id/options", () => { + let colorOption + let sizeOption + + beforeEach(async () => { + colorOption = ( + await api.post( + "/admin/product-options", + { title: "Color", values: ["Red", "Blue"] }, + adminHeaders + ) + ).data.product_option + + sizeOption = ( + await api.post( + "/admin/product-options", + { title: "Size", values: ["L", "M"] }, + adminHeaders + ) + ).data.product_option + }) + + it("should link existing options to product", async () => { + const payload = { + add: [colorOption.id, sizeOption.id], + } + + const response = await api.post( + `/admin/products/${baseProduct.id}/options`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(4) // 2 new ones and 2 it already had + expect(response.data.product.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: baseProduct.options[0].id, + }), + expect.objectContaining({ + id: baseProduct.options[1].id, + }), + expect.objectContaining({ + id: colorOption.id, + }), + expect.objectContaining({ + id: sizeOption.id, + }), + ]) + ) + }) + + it("should unlink existing options from product", async () => { + let response = await api.post( + `/admin/products/${baseProduct.id}/options`, + { + add: [colorOption.id, sizeOption.id], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(4) // 2 new ones and 2 it already had + + const payload = { + remove: [colorOption.id, sizeOption.id], + } + + response = await api.post( + `/admin/products/${baseProduct.id}/options`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(2) + }) + + it("should link and unlink existing options to/from product", async () => { + const payload = { + add: [colorOption.id, sizeOption.id], + remove: [baseProduct.options[0].id, baseProduct.options[1].id], + } + + const response = await api.post( + `/admin/products/${baseProduct.id}/options`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(2) + expect(response.data.product.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: colorOption.id, + }), + expect.objectContaining({ + id: sizeOption.id, + }), + ]) + ) + }) + + it("should link a new options to product", async () => { + const payload = { + add: [ + colorOption.id, + sizeOption.id, + { title: "new", values: ["A", "B"] }, + ], + remove: [baseProduct.options[0].id, baseProduct.options[1].id], + } + + const response = await api.post( + `/admin/products/${baseProduct.id}/options`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(3) + expect(response.data.product.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: colorOption.id, + }), + expect.objectContaining({ + id: sizeOption.id, + }), + expect.objectContaining({ + title: "new", + }), + ]) + ) + }) + }) + describe("testing for soft-deletion + uniqueness on handles, collection and variant properties", () => { it("successfully deletes a product", async () => { const response = await api diff --git a/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts b/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts index 8362b0b3e3084..1d36abee09f5f 100644 --- a/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts +++ b/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts @@ -61,29 +61,30 @@ export const linkProductOptionsToProductWorkflowId = export const linkProductOptionsToProductWorkflow = createWorkflow( linkProductOptionsToProductWorkflowId, (input: WorkflowData) => { - const optionsToCreate = transform({ input }, ({ input }) => { - return (input.add ?? []).filter((option) => !isString(option)) - }) as ProductTypes.CreateProductOptionDTO[] + const { toCreate, toAdd } = transform({ input }, ({ input }) => { + const toCreate: ProductTypes.CreateProductOptionDTO[] = [] + const toAdd: string[] = [] + for (const option of input.add ?? []) { + isString(option) ? toAdd.push(option) : toCreate.push(option) + } + + return { toCreate, toAdd } + }) const createdIds = when( "creating-product-options", - { optionsToCreate }, - ({ optionsToCreate }) => optionsToCreate.length > 0 + { toCreate }, + ({ toCreate }) => toCreate.length > 0 ).then(() => { - const createdOptions = createProductOptionsStep(optionsToCreate) - - return transform({ createdOptions }, ({ createdOptions }) => { - return createdOptions.map((option) => option.id) - }) + const createdOptions = createProductOptionsStep(toCreate) + return transform({ createdOptions }, ({ createdOptions }) => + createdOptions.map((option) => option.id) + ) }) const toAddProductOptionIds = transform( - { input, createdIds }, - ({ input, createdIds }) => { - return (input.add ?? []) - .filter((option) => isString(option)) - .concat(createdIds ? createdIds : []) - } + { toAdd, createdIds }, + ({ toAdd, createdIds }) => toAdd.concat(createdIds ?? []) ) const productOptions = linkProductOptionsToProductStep({ diff --git a/packages/medusa/src/api/admin/product-categories/middlewares.ts b/packages/medusa/src/api/admin/product-categories/middlewares.ts index c8fa2397f0369..fb65b6c87547e 100644 --- a/packages/medusa/src/api/admin/product-categories/middlewares.ts +++ b/packages/medusa/src/api/admin/product-categories/middlewares.ts @@ -58,7 +58,6 @@ export const adminProductCategoryRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["DELETE"], matcher: "/admin/product-categories/:id", - middlewares: [], }, { method: ["POST"], diff --git a/packages/medusa/src/api/admin/product-options/middlewares.ts b/packages/medusa/src/api/admin/product-options/middlewares.ts index 71b46053f3453..0679c2e7bcd7d 100644 --- a/packages/medusa/src/api/admin/product-options/middlewares.ts +++ b/packages/medusa/src/api/admin/product-options/middlewares.ts @@ -57,6 +57,5 @@ export const adminProductOptionRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["DELETE"], matcher: "/admin/product-options/:id", - middlewares: [], }, ] diff --git a/packages/medusa/src/api/admin/product-tags/middlewares.ts b/packages/medusa/src/api/admin/product-tags/middlewares.ts index 4fb15e9f4eef2..67387f7ee7a55 100644 --- a/packages/medusa/src/api/admin/product-tags/middlewares.ts +++ b/packages/medusa/src/api/admin/product-tags/middlewares.ts @@ -1,9 +1,6 @@ import * as QueryConfig from "./query-config" import { MiddlewareRoute } from "@medusajs/framework/http" -import { - validateAndTransformBody, - validateAndTransformQuery, -} from "@medusajs/framework" +import { validateAndTransformBody, validateAndTransformQuery, } from "@medusajs/framework" import { AdminCreateProductTag, AdminGetProductTagParams, @@ -58,6 +55,5 @@ export const adminProductTagRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["DELETE"], matcher: "/admin/product-tags/:id", - middlewares: [], }, ] From 046d76792834b5f4e4e813981531cc8897528a5e Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 6 Nov 2025 09:02:42 -0500 Subject: [PATCH 30/36] query graph --- .../api/admin/product-options/[id]/route.ts | 29 ++++++++++++------- .../src/api/admin/product-options/route.ts | 23 ++++++++------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/medusa/src/api/admin/product-options/[id]/route.ts b/packages/medusa/src/api/admin/product-options/[id]/route.ts index 555ebc67fb85b..f7dde92eae650 100644 --- a/packages/medusa/src/api/admin/product-options/[id]/route.ts +++ b/packages/medusa/src/api/admin/product-options/[id]/route.ts @@ -5,7 +5,6 @@ import { import { AuthenticatedMedusaRequest, MedusaResponse, - refetchEntity, } from "@medusajs/framework/http" import { @@ -13,16 +12,21 @@ import { AdminUpdateProductOptionType, } from "../validators" import { HttpTypes } from "@medusajs/framework/types" -import { MedusaError } from "@medusajs/framework/utils" +import { + ContainerRegistrationKeys, + MedusaError, +} from "@medusajs/framework/utils" export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const product_option = await refetchEntity({ + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { + data: [product_option], + } = await query.graph({ entity: "product_option", - idOrFilter: req.params.id, - scope: req.scope, + filters: { id: req.params.id }, fields: req.queryConfig.fields, }) @@ -33,10 +37,12 @@ export const POST = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const existingProductOption = await refetchEntity({ + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { + data: [existingProductOption], + } = await query.graph({ entity: "product_option", - idOrFilter: req.params.id, - scope: req.scope, + filters: { id: req.params.id }, fields: ["id"], }) @@ -54,10 +60,11 @@ export const POST = async ( }, }) - const product_option = await refetchEntity({ + const { + data: [product_option], + } = await query.graph({ entity: "product_option", - idOrFilter: result[0].id, - scope: req.scope, + filters: { id: result[0].id }, fields: req.queryConfig.fields, }) diff --git a/packages/medusa/src/api/admin/product-options/route.ts b/packages/medusa/src/api/admin/product-options/route.ts index 911f2344d4463..76bc24ed609eb 100644 --- a/packages/medusa/src/api/admin/product-options/route.ts +++ b/packages/medusa/src/api/admin/product-options/route.ts @@ -1,30 +1,29 @@ import { AuthenticatedMedusaRequest, MedusaResponse, - refetchEntities, - refetchEntity, } from "@medusajs/framework/http" import { createProductOptionsWorkflow } from "@medusajs/core-flows" import { HttpTypes } from "@medusajs/framework/types" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const { data: product_options, metadata } = await refetchEntities({ + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: product_options, metadata } = await query.graph({ entity: "product_option", - idOrFilter: req.filterableFields, - scope: req.scope, + filters: req.filterableFields, fields: req.queryConfig.fields, pagination: req.queryConfig.pagination, }) res.json({ product_options, - count: metadata.count, - offset: metadata.skip, - limit: metadata.take, + count: metadata!.count, + offset: metadata!.skip, + limit: metadata!.take, }) } @@ -38,10 +37,12 @@ export const POST = async ( input: { product_options: input }, }) - const productOption = await refetchEntity({ + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { + data: [productOption], + } = await query.graph({ entity: "product_option", - idOrFilter: result[0].id, - scope: req.scope, + filters: { id: result[0].id }, fields: req.queryConfig.fields, }) From df36394faeff8f9f248a4cb9767e3da64d80e31f Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 6 Nov 2025 11:43:50 -0500 Subject: [PATCH 31/36] comment about moving error --- .../product-option/product-option.spec.ts | 16 ++++++++++++++ .../product/steps/update-product-options.ts | 8 +++++++ .../api/admin/product-options/[id]/route.ts | 22 ++----------------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/integration-tests/http/__tests__/product-option/product-option.spec.ts b/integration-tests/http/__tests__/product-option/product-option.spec.ts index 0ee8aecd2480c..eae06b374de20 100644 --- a/integration-tests/http/__tests__/product-option/product-option.spec.ts +++ b/integration-tests/http/__tests__/product-option/product-option.spec.ts @@ -242,6 +242,22 @@ medusaIntegrationTestRunner({ }) ) }) + + it("should throw when trying to update an option that does not exist", async () => { + const error = await api.post( + `/admin/product-options/iDontExist`, + { + is_exclusive: false, + }, + adminHeaders + ).catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data).toEqual({ + message: "Product option with id \"iDontExist\" not found", + type: "not_found" + }) + }) }) describe("DELETE /admin/product-options/[id]", () => { diff --git a/packages/core/core-flows/src/product/steps/update-product-options.ts b/packages/core/core-flows/src/product/steps/update-product-options.ts index fe67f776fc66d..da927663ed7de 100644 --- a/packages/core/core-flows/src/product/steps/update-product-options.ts +++ b/packages/core/core-flows/src/product/steps/update-product-options.ts @@ -4,6 +4,7 @@ import type { } from "@medusajs/framework/types" import { getSelectsAndRelationsFromObjectArray, + MedusaError, Modules, } from "@medusajs/framework/utils" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" @@ -49,6 +50,13 @@ export const updateProductOptionsStep = createStep( relations: ["values"], }) + if (!prevData.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product option with id "${data.selector.id}" not found` + ) + } + const productOptions = await service.updateProductOptions( data.selector, data.update diff --git a/packages/medusa/src/api/admin/product-options/[id]/route.ts b/packages/medusa/src/api/admin/product-options/[id]/route.ts index f7dde92eae650..cd9b2448c4553 100644 --- a/packages/medusa/src/api/admin/product-options/[id]/route.ts +++ b/packages/medusa/src/api/admin/product-options/[id]/route.ts @@ -12,10 +12,7 @@ import { AdminUpdateProductOptionType, } from "../validators" import { HttpTypes } from "@medusajs/framework/types" -import { - ContainerRegistrationKeys, - MedusaError, -} from "@medusajs/framework/utils" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" export const GET = async ( req: AuthenticatedMedusaRequest, @@ -37,22 +34,6 @@ export const POST = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) - const { - data: [existingProductOption], - } = await query.graph({ - entity: "product_option", - filters: { id: req.params.id }, - fields: ["id"], - }) - - if (!existingProductOption) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Product option with id "${req.params.id}" not found` - ) - } - const { result } = await updateProductOptionsWorkflow(req.scope).run({ input: { selector: { id: req.params.id }, @@ -60,6 +41,7 @@ export const POST = async ( }, }) + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) const { data: [product_option], } = await query.graph({ From a576bb2500157f929d651372964e8a03ed53f603 Mon Sep 17 00:00:00 2001 From: willbouch Date: Thu, 6 Nov 2025 11:44:05 -0500 Subject: [PATCH 32/36] create options in parallel --- .../process-product-options-for-import.ts | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts index c4a17afbc1d49..f61c74959553b 100644 --- a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts +++ b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts @@ -12,7 +12,7 @@ export const processProductOptionsForImportStepId = export type ProcessProductOptionsForImportInput = { products: (Omit & { - options: ProductTypes.CreateProductOptionDTO[] + options?: ProductTypes.CreateProductOptionDTO[] })[] } @@ -32,32 +32,52 @@ export const processProductOptionsForImportStep = createStep( Modules.PRODUCT ) - const createdOptionIds: string[] = [] const processedProducts: UpdateProductWorkflowInputDTO[] = [] - for (const product of data.products) { - if (product.options?.length) { - const createdOptions = await productService.createProductOptions( - product.options - ) + const allOptions: ProductTypes.CreateProductOptionDTO[] = [] + const productIndices: number[] = [] // Maps option index to product index + + data.products.forEach((product, index) => { + (product.options ?? []).forEach((option) => { + allOptions.push(option) + productIndices.push(index) + }) + }) + + const createdOptions = + allOptions.length > 0 + ? await productService.createProductOptions(allOptions) + : [] + const createdOptionIds = createdOptions.map((opt) => opt.id) - createdOptionIds.push(...createdOptions.map((opt) => opt.id)) + const productOptionsMap = new Map< + number, + ProductTypes.ProductOptionDTO[] + >() + createdOptions.forEach((option, index) => { + const productIndex = productIndices[index] + if (!productOptionsMap.has(productIndex)) { + productOptionsMap.set(productIndex, []) + } + productOptionsMap.get(productIndex)!.push(option) + }) - const optionTitleToIdMap = new Map() - createdOptions.forEach((option) => { - optionTitleToIdMap.set(option.title, option.id) - }) + data.products.forEach((product, index) => { + const createdOptionsForProduct = productOptionsMap.get(index) + if (createdOptionsForProduct && createdOptionsForProduct.length) { // Transform product to use option_ids instead of options const transformedProduct: any = deepCopy(product) delete transformedProduct.options - transformedProduct.option_ids = createdOptions.map((opt) => opt.id) + transformedProduct.option_ids = createdOptionsForProduct.map( + (opt) => opt.id + ) processedProducts.push(transformedProduct) } else { processedProducts.push(product) } - } + }) return new StepResponse(processedProducts, createdOptionIds) }, From a7fc30648af3ee6ea2c89acba4646fad9fd1fd56 Mon Sep 17 00:00:00 2001 From: willbouch Date: Fri, 7 Nov 2025 12:43:25 -0500 Subject: [PATCH 33/36] comments --- .../steps/process-product-options-for-import.ts | 5 ++++- ...=> create-and-link-product-options-to-product.ts} | 10 +++++----- .../core/core-flows/src/product/workflows/index.ts | 2 +- .../src/api/admin/products/[id]/options/route.ts | 4 ++-- packages/medusa/src/api/admin/products/validators.ts | 12 +++++++++++- 5 files changed, 23 insertions(+), 10 deletions(-) rename packages/core/core-flows/src/product/workflows/{link-product-options-to-product.ts => create-and-link-product-options-to-product.ts} (89%) diff --git a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts index f61c74959553b..9898c13972932 100644 --- a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts +++ b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts @@ -46,7 +46,10 @@ export const processProductOptionsForImportStep = createStep( const createdOptions = allOptions.length > 0 - ? await productService.createProductOptions(allOptions) + ? await productService.createProductOptions(allOptions.map(option => ({ + ...option, + is_exclusive: true // Until we change the CSV logic to pass option id in there, we have to default to exclusive + }))) : [] const createdOptionIds = createdOptions.map((opt) => opt.id) diff --git a/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts b/packages/core/core-flows/src/product/workflows/create-and-link-product-options-to-product.ts similarity index 89% rename from packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts rename to packages/core/core-flows/src/product/workflows/create-and-link-product-options-to-product.ts index 1d36abee09f5f..f8cb09dc0705e 100644 --- a/packages/core/core-flows/src/product/workflows/link-product-options-to-product.ts +++ b/packages/core/core-flows/src/product/workflows/create-and-link-product-options-to-product.ts @@ -30,8 +30,8 @@ export type LinkProductOptionsToProductWorkflowInput = { remove?: string[] } -export const linkProductOptionsToProductWorkflowId = - "link-product-options-to-product" +export const createAndLinkProductOptionsToProductWorkflowId = + "create-and-link-product-options-to-product" /** * This workflow adds/removes one or more product options to/from a product. It's used by the [TODO](TODO). * This workflow also creates non-existing product options before adding them to the product. @@ -39,7 +39,7 @@ export const linkProductOptionsToProductWorkflowId = * You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around product-option and product association. * * @example - * const { result } = await linkProductOptionsToProductWorkflow(container) + * const { result } = await createAndLinkProductOptionsToProductWorkflow(container) * .run({ * input: { * product_id: "prod_123" @@ -58,8 +58,8 @@ export const linkProductOptionsToProductWorkflowId = * * Add/remove one or more product options to/from a product. */ -export const linkProductOptionsToProductWorkflow = createWorkflow( - linkProductOptionsToProductWorkflowId, +export const createAndLinkProductOptionsToProductWorkflow = createWorkflow( + createAndLinkProductOptionsToProductWorkflowId, (input: WorkflowData) => { const { toCreate, toAdd } = transform({ input }, ({ input }) => { const toCreate: ProductTypes.CreateProductOptionDTO[] = [] diff --git a/packages/core/core-flows/src/product/workflows/index.ts b/packages/core/core-flows/src/product/workflows/index.ts index 6aa4d43a2b62e..6365eaadf0842 100644 --- a/packages/core/core-flows/src/product/workflows/index.ts +++ b/packages/core/core-flows/src/product/workflows/index.ts @@ -16,7 +16,7 @@ export * from "./delete-product-types" export * from "./delete-product-tags" export * from "./delete-product-variants" export * from "./delete-products" -export * from "./link-product-options-to-product" +export * from "./create-and-link-product-options-to-product" export * from "./update-collections" export * from "./update-product-options" export * from "./update-product-types" diff --git a/packages/medusa/src/api/admin/products/[id]/options/route.ts b/packages/medusa/src/api/admin/products/[id]/options/route.ts index 61975ea0269a8..9dc70ff2d4447 100644 --- a/packages/medusa/src/api/admin/products/[id]/options/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/options/route.ts @@ -6,7 +6,7 @@ import { } from "@medusajs/framework/http" import { HttpTypes } from "@medusajs/framework/types" import { remapKeysForProduct, remapProductResponse } from "../../helpers" -import { linkProductOptionsToProductWorkflow } from "@medusajs/core-flows" +import { createAndLinkProductOptionsToProductWorkflow } from "@medusajs/core-flows" export const GET = async ( req: AuthenticatedMedusaRequest, @@ -35,7 +35,7 @@ export const POST = async ( ) => { const productId = req.params.id - await linkProductOptionsToProductWorkflow(req.scope).run({ + await createAndLinkProductOptionsToProductWorkflow(req.scope).run({ input: { product_id: productId, ...req.validatedBody, diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index b18f42988fb65..b72bd6d1909ff 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -240,7 +240,7 @@ export const CreateProduct = z categories: z.array(IdAssociation).optional(), tags: z.array(IdAssociation).optional(), options: z - .array(z.union([AdminCreateProductOption, z.object({ id: z.string() })])) + .array(z.union([AdminCreateProductOption, IdAssociation])) .optional(), variants: z.array(CreateProductVariant).optional(), sales_channels: z.array(z.object({ id: z.string() })).optional(), @@ -265,6 +265,16 @@ export const UpdateProduct = z title: z.string().optional(), discountable: booleanString().optional(), is_giftcard: booleanString().optional(), + options: z.any().superRefine((val, ctx) => { + if (val !== undefined) { + // TODO set version and link to release notes + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "The 'options' property was removed in version X.Y.Z. Please remove it from your request payload.", + }) + } + }), option_ids: z.array(IdAssociation).optional(), variants: z.array(UpdateProductVariant).optional(), status: statusEnum.optional(), From d1aa1c1e480d2d14a32c72ea87e348a568d163ae Mon Sep 17 00:00:00 2001 From: willbouch Date: Wed, 12 Nov 2025 15:09:51 -0500 Subject: [PATCH 34/36] small change in doc --- .../src/product/steps/link-product-options-to-product.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts b/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts index f73f42eac4931..866ccd03f7690 100644 --- a/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts +++ b/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts @@ -28,7 +28,7 @@ export const linkProductOptionsToProductStepId = * @example * const data = linkProductOptionsToProductStep({ * product_id: "prod_123", - * product_option_ids: ["opt_123", "opt_321"] + * add: ["opt_123", "opt_321"] * }) */ export const linkProductOptionsToProductStep = createStep( From 50b44c8f1eede8c0d30ff760d68786df258451dc Mon Sep 17 00:00:00 2001 From: willbouch Date: Wed, 12 Nov 2025 15:26:18 -0500 Subject: [PATCH 35/36] migration name --- .../modules/product/src/migrations/Migration20251022153442.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/product/src/migrations/Migration20251022153442.ts b/packages/modules/product/src/migrations/Migration20251022153442.ts index a12c48d5f99b7..a766c550f33ab 100644 --- a/packages/modules/product/src/migrations/Migration20251022153442.ts +++ b/packages/modules/product/src/migrations/Migration20251022153442.ts @@ -1,6 +1,6 @@ import { Migration } from "@mikro-orm/migrations" -export class Migration20251023171945 extends Migration { +export class Migration20251022153442 extends Migration { override async up(): Promise { this.addSql(` create table if not exists "product_product_option" ( From ddd4f360b3599f649661de61229289e186e6d418 Mon Sep 17 00:00:00 2001 From: willbouch Date: Fri, 14 Nov 2025 14:20:26 -0500 Subject: [PATCH 36/36] fix test --- .../workflows/create-fulfillment.spec.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts b/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts index 2f00faffc3ed9..cd5960f0ff39d 100644 --- a/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts +++ b/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts @@ -15,12 +15,7 @@ import { ShippingOptionDTO, StockLocationDTO, } from "@medusajs/types" -import { - BigNumber, - ContainerRegistrationKeys, - Modules, - remoteQueryObjectFromString, -} from "@medusajs/utils" +import { BigNumber, ContainerRegistrationKeys, Modules, remoteQueryObjectFromString, } from "@medusajs/utils" jest.setTimeout(500000) @@ -160,7 +155,7 @@ async function prepareDataFixtures({ container }) { }, { [Modules.PRODUCT]: { - variant_id: product.variants[0].id, + variant_id: product.variants.find((v) => v.sku === variantSkuWithInventory)!.id, }, [Modules.INVENTORY]: { inventory_item_id: inventoryItem.id, @@ -233,15 +228,19 @@ async function prepareDataFixtures({ container }) { async function createOrderFixture({ container, product, location }) { const orderService: IOrderModuleService = container.resolve(Modules.ORDER) + + const variantWithInventory = product.variants.find((v) => v.sku === variantSkuWithInventory)! + const variantWithoutInventory = product.variants.find((v) => v.sku === "test-variant-no-inventory")! + let order = await orderService.createOrders({ region_id: "test_region_id", email: "foo@bar.com", items: [ { title: "Custom Item 2", - variant_sku: product.variants[0].sku, - variant_title: product.variants[0].title, - variant_id: product.variants[0].id, + variant_sku: variantWithInventory.sku, + variant_title: variantWithInventory.title, + variant_id: variantWithInventory.id, quantity: 1, unit_price: 50, adjustments: [ @@ -256,9 +255,9 @@ async function createOrderFixture({ container, product, location }) { }, { title: product.title, - variant_sku: product.variants[1].sku, - variant_title: product.variants[1].title, - variant_id: product.variants[1].id, + variant_sku: variantWithoutInventory.sku, + variant_title: variantWithoutInventory.title, + variant_id: variantWithoutInventory.id, quantity: 1, unit_price: 200, },