Skip to content

Commit a92ca32

Browse files
authored
feat(product-category): add external_id to product-category (#14799)
## Summary **What** — What changes are introduced in this PR? This PR adds `external_id` to the `product-category` type. It is available on both /admin and /store, and can be used as filter. **Why** — Why are these changes relevant or necessary? To facilitate runtime lookup from values derived from 3rd party systems (PIM, ERP, Recommendations, CMS-stored-references) of the product-category. Allows addressing data via its native/real-world identifier, rather than synthetic/instance-specific database identifier. **How** — How have these changes been implemented? Added migration to add field. Added field to schemas and queryInfo blocks. **Testing** — How have these changes been tested, or how can the reviewer test the feature? Integration tests included. --- ## Examples Provide examples or code snippets that demonstrate how this feature works, or how it can be used in practice. This helps with documentation and ensures maintainers can quickly understand and verify the change. ```ts await sdk.admin.productCategory.create({ name: 'test-category', external_id: 'my-external_id' }); ``` ```ts sdk.store.category.list({ external_id: 'my-external_id' }); ``` --- ## Checklist Please ensure the following before requesting a review: - [X] I have added a **changeset** for this PR - Every non-breaking change should be marked as a **patch** - To add a changeset, run `yarn changeset` and follow the prompts - [X] The changes are covered by relevant **tests** - [X] I have verified the code works as intended locally - [X] I have linked the related issue(s) if applicable --- ## Additional Context Add any additional context, related issues, or references that might help the reviewer understand this PR. --- > [!NOTE] > **Medium Risk** > Adds a new nullable DB column and threads it through admin/store API validation, default field selection, and filtering, which could affect migrations and query behavior. Changes are straightforward but touch persisted schema and public API responses. > > **Overview** > Product categories now support a nullable `external_id`, persisted via a new MikroORM migration and model/schema update. > > The admin and store APIs include `external_id` in default query fields, accept it on create/update payloads, and allow filtering by it (validators and types updated accordingly). > > Integration tests are expanded/added to cover returning `external_id`, creating/updating it, and filtering by it across module, admin HTTP, and store HTTP routes. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2872ea5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup>
1 parent 7309fd2 commit a92ca32

File tree

15 files changed

+335
-1
lines changed

15 files changed

+335
-1
lines changed

.changeset/yummy-teams-matter.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@medusajs/product": patch
3+
"integration-tests-http": patch
4+
"@medusajs/types": patch
5+
"@medusajs/medusa": patch
6+
---
7+
8+
add external_id to product-category

integration-tests/http/__tests__/product-category/admin/product-category.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ medusaIntegrationTestRunner({
5353
name: "category-0",
5454
parent_category_id: productCategoryParent.id,
5555
rank: 0,
56+
external_id: "ext-id-0",
5657
description: "category-0",
5758
},
5859
adminHeaders
@@ -110,6 +111,7 @@ medusaIntegrationTestRunner({
110111
id: productCategory.id,
111112
name: productCategory.name,
112113
handle: productCategory.handle,
114+
external_id: productCategory.external_id,
113115
parent_category_id: productCategoryParent.id,
114116
category_children: [
115117
expect.objectContaining({
@@ -226,6 +228,7 @@ medusaIntegrationTestRunner({
226228
"/admin/product-categories",
227229
{
228230
name: "sweater",
231+
external_id: "ext-id-sweater",
229232
parent_category_id: productCategoryParent.id,
230233
is_internal: true,
231234
},
@@ -365,6 +368,8 @@ medusaIntegrationTestRunner({
365368
expect(response.data.product_categories).toHaveLength(7) // created in beforeEach
366369
})
367370

371+
372+
368373
it("gets list of product category with immediate children and parents", async () => {
369374
// BREAKING: To get the children tree, the query param include_descendants_tree must be used
370375
const path = `/admin/product-categories?limit=7`
@@ -638,6 +643,18 @@ medusaIntegrationTestRunner({
638643
)
639644
})
640645

646+
it('filters based on external id', async () => {
647+
648+
const response = await api.get(
649+
`/admin/product-categories?external_id=${productCategory.external_id}`,
650+
adminHeaders
651+
)
652+
653+
expect(response.status).toEqual(200)
654+
expect(response.data.product_categories).toHaveLength(1)
655+
expect(response.data.product_categories[0].id).toEqual(productCategory.id)
656+
});
657+
641658
it("filters based on parent category", async () => {
642659
const response = await api.get(
643660
`/admin/product-categories?parent_category_id=${productCategoryParent.id}&limit=7`,
@@ -839,6 +856,46 @@ medusaIntegrationTestRunner({
839856
)
840857
})
841858

859+
it("successfully creates a product category with an external id", async () => {
860+
const payload = {
861+
name: "test",
862+
handle: "test",
863+
is_internal: true,
864+
parent_category_id: productCategory.id,
865+
description: "test",
866+
external_id: "ext-id-0",
867+
}
868+
869+
const response = await api.post(
870+
`/admin/product-categories`,
871+
payload,
872+
adminHeaders
873+
)
874+
875+
expect(response.status).toEqual(200)
876+
expect(response.data).toEqual(
877+
expect.objectContaining({
878+
product_category: expect.objectContaining({
879+
name: payload.name,
880+
description: payload.description,
881+
handle: payload.handle,
882+
is_internal: payload.is_internal,
883+
is_active: false,
884+
external_id: payload.external_id,
885+
created_at: expect.any(String),
886+
updated_at: expect.any(String),
887+
parent_category: expect.objectContaining({
888+
id: productCategory.id,
889+
}),
890+
category_children: [],
891+
rank: 0,
892+
}),
893+
})
894+
)
895+
})
896+
897+
898+
842899
it("successfully creates a product category with a rank", async () => {
843900
const payload = {
844901
name: "test",
@@ -1202,6 +1259,7 @@ medusaIntegrationTestRunner({
12021259
handle: "test",
12031260
is_internal: true,
12041261
is_active: true,
1262+
external_id: "ext-id-2",
12051263
parent_category_id: productCategory.id,
12061264
},
12071265
adminHeaders
@@ -1220,6 +1278,7 @@ medusaIntegrationTestRunner({
12201278
parent_category_id: productCategory.id,
12211279
category_children: [],
12221280
rank: 1,
1281+
external_id: "ext-id-2",
12231282
}),
12241283
})
12251284
)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
2+
import { IStoreModuleService } from "@medusajs/types"
3+
import {
4+
Modules
5+
} from "@medusajs/utils"
6+
import qs from "qs"
7+
import {
8+
adminHeaders,
9+
createAdminUser,
10+
generatePublishableKey,
11+
generateStoreHeaders,
12+
} from "../../../../helpers/create-admin-user"
13+
import { createAuthenticatedCustomer } from "../../../../modules/helpers/create-authenticated-customer"
14+
15+
jest.setTimeout(30000)
16+
17+
medusaIntegrationTestRunner({
18+
testSuite: ({ dbConnection, api, getContainer }) => {
19+
let store
20+
let appContainer
21+
let productCategory1
22+
let productCategory2
23+
let storeHeaders
24+
let publishableKey
25+
let storeHeadersWithCustomer
26+
let customer
27+
28+
const createCategory = async (data, productIds) => {
29+
const response = await api.post(
30+
"/admin/product-categories",
31+
data,
32+
adminHeaders
33+
)
34+
35+
await api.post(
36+
`/admin/product-categories/${response.data.product_category.id}/products`,
37+
{ add: productIds },
38+
adminHeaders
39+
)
40+
41+
const response2 = await api.get(
42+
`/admin/product-categories/${response.data.product_category.id}?fields=*products`,
43+
adminHeaders
44+
)
45+
46+
return response2.data.product_category
47+
}
48+
49+
50+
beforeEach(async () => {
51+
appContainer = getContainer()
52+
publishableKey = await generatePublishableKey(appContainer)
53+
storeHeaders = generateStoreHeaders({ publishableKey })
54+
await createAdminUser(dbConnection, adminHeaders, appContainer)
55+
const result = await createAuthenticatedCustomer(api, storeHeaders, {
56+
first_name: "tony",
57+
last_name: "stark",
58+
email: "tony@stark-industries.com",
59+
})
60+
61+
customer = result.customer
62+
storeHeadersWithCustomer = {
63+
headers: {
64+
...storeHeaders.headers,
65+
authorization: `Bearer ${result.jwt}`,
66+
},
67+
}
68+
69+
const storeModule: IStoreModuleService = appContainer.resolve(
70+
Modules.STORE
71+
)
72+
// A default store is created when the app is started, so we want to delete that one and create one specifically for our tests.
73+
const defaultId = (await api.get("/admin/stores", adminHeaders)).data
74+
.stores?.[0]?.id
75+
if (defaultId) {
76+
await storeModule.deleteStores(defaultId)
77+
}
78+
79+
store = await storeModule.createStores({
80+
name: "New store",
81+
supported_currencies: [
82+
{ currency_code: "usd", is_default: true },
83+
{ currency_code: "dkk" },
84+
],
85+
})
86+
87+
})
88+
89+
describe("Get product-categories based on publishable key", () => {
90+
91+
beforeEach(async () => {
92+
93+
productCategory1 = await createCategory(
94+
{ name: "test", is_internal: false, is_active: true, external_id: "ext-id-1" },
95+
[]
96+
)
97+
98+
99+
productCategory2 = await createCategory(
100+
{ name: "test2", is_internal: false, is_active: true, handle: 'test-2', external_id: "ext-id-2" },
101+
[]
102+
)
103+
})
104+
105+
it("returns the external_id on id lookups", async () => {
106+
const response = await api.get(
107+
`/store/product-categories/${productCategory1.id}`,
108+
storeHeaders
109+
)
110+
111+
expect(response.status).toBe(200)
112+
expect(response.data.product_category).toEqual(
113+
expect.objectContaining({
114+
id: productCategory1.id,
115+
external_id: "ext-id-1",
116+
})
117+
)
118+
})
119+
120+
it("it filters on the external_id field", async () => {
121+
122+
123+
124+
const searchParam = qs.stringify({
125+
external_id: "ext-id-2",
126+
})
127+
128+
const response = await api.get(
129+
`/store/product-categories?${searchParam}`,
130+
storeHeaders
131+
)
132+
133+
expect(response.status).toBe(200)
134+
expect(response.data.product_categories).toEqual(
135+
expect.arrayContaining([
136+
expect.objectContaining({
137+
id: productCategory2.id,
138+
external_id: "ext-id-2",
139+
}),
140+
])
141+
)
142+
})
143+
})
144+
},
145+
})

packages/core/types/src/http/product-category/admin/payloads.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export interface AdminCreateProductCategory {
3030
* The category's ranking among its sibling categories.
3131
*/
3232
rank?: number
33+
/**
34+
* An external identifier for the product category.
35+
*/
36+
external_id?: string | null
3337
/**
3438
* Key-value pairs of custom data.
3539
*/
@@ -68,6 +72,10 @@ export interface AdminUpdateProductCategory {
6872
* The category's ranking among its sibling categories.
6973
*/
7074
rank?: number
75+
/**
76+
* An external identifier for the product category.
77+
*/
78+
external_id?: string | null
7179
/**
7280
* Key-value pairs of custom data.
7381
*/

packages/core/types/src/http/product-category/common.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export interface BaseProductCategory {
3232
* The category's ranking among sibling categories.
3333
*/
3434
rank: number | null
35+
/**
36+
* An external identifier for the product category.
37+
*/
38+
external_id: string | null
3539
/**
3640
* The ID of the category's parent.
3741
*/
@@ -93,6 +97,10 @@ export interface BaseProductCategoryListParams
9397
* Filter by the category's handle(s).
9498
*/
9599
handle?: string | string[]
100+
/**
101+
* Filter by the category's external ID(s).
102+
*/
103+
external_id?: string | string[] | null
96104
/**
97105
* Filter by whether the category is active.
98106
*/

packages/core/types/src/product/common.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,10 @@ export interface ProductCategoryDTO {
310310
* The ranking of the product category among sibling categories.
311311
*/
312312
rank: number
313+
/**
314+
* An external identifier for the product category, such as an ID from a third-party system.
315+
*/
316+
external_id: string | null
313317
/**
314318
* The ranking of the product category among sibling categories.
315319
*/
@@ -384,6 +388,10 @@ export interface CreateProductCategoryDTO {
384388
* The ID of the parent product category, if it has any.
385389
*/
386390
parent_category_id?: string | null
391+
/**
392+
* An external identifier for the product category, such as an ID from a third-party system.
393+
*/
394+
external_id?: string | null
387395
/**
388396
* Holds custom data in key-value pairs.
389397
*/
@@ -431,6 +439,10 @@ export interface UpdateProductCategoryDTO {
431439
* The ID of the parent product category, if it has any.
432440
*/
433441
parent_category_id?: string | null
442+
/**
443+
* An external identifier for the product category, such as an ID from a third-party system.
444+
*/
445+
external_id?: string | null
434446
/**
435447
* Holds custom data in key-value pairs.
436448
*/
@@ -1012,6 +1024,10 @@ export interface FilterableProductCategoryProps
10121024
* Filter product categories by whether they're internal.
10131025
*/
10141026
is_internal?: boolean
1027+
/**
1028+
* Filter product categories by external ID.
1029+
*/
1030+
external_id?: string | string[] | null
10151031
/**
10161032
* Whether to include children of retrieved product categories.
10171033
*/

packages/medusa/src/api/admin/product-categories/query-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const defaults = [
1010
"is_active",
1111
"is_internal",
1212
"rank",
13+
"external_id",
1314
"parent_category_id",
1415
"created_at",
1516
"updated_at",
@@ -26,6 +27,7 @@ export const allowed = [
2627
"is_active",
2728
"is_internal",
2829
"rank",
30+
"external_id",
2931
"parent_category_id",
3032
"created_at",
3133
"updated_at",

packages/medusa/src/api/admin/product-categories/validators.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const AdminProductCategoriesParamsFields = z.object({
2626
description: z.union([z.string(), z.array(z.string())]).optional(),
2727
handle: z.union([z.string(), z.array(z.string())]).optional(),
2828
parent_category_id: z.union([z.string(), z.array(z.string())]).optional(),
29+
external_id: z.union([z.string(), z.array(z.string()), z.null()]).optional(),
2930
include_ancestors_tree: booleanString().optional(),
3031
include_descendants_tree: booleanString().optional(),
3132
is_internal: booleanString().optional(),
@@ -53,6 +54,7 @@ export const CreateProductCategory = z
5354
is_internal: z.boolean().optional(),
5455
is_active: z.boolean().optional(),
5556
parent_category_id: z.string().nullish(),
57+
external_id: z.string().nullish(),
5658
metadata: z.record(z.unknown()).nullish(),
5759
rank: z.number().nonnegative().optional(),
5860
})
@@ -74,6 +76,7 @@ export const UpdateProductCategory = z
7476
is_internal: z.boolean().optional(),
7577
is_active: z.boolean().optional(),
7678
parent_category_id: z.string().nullish(),
79+
external_id: z.string().nullish(),
7780
metadata: z.record(z.unknown()).nullish(),
7881
rank: z.number().nonnegative().optional(),
7982
})

0 commit comments

Comments
 (0)