@thorprovider/adapters 5.0.8 → 5.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +41 -2
- package/dist/index.d.ts +41 -2
- package/dist/index.js +152 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +152 -33
- package/dist/index.mjs.map +1 -1
- package/docs/multi-tenant-channel-scoping.md +42 -4
- package/package.json +2 -2
- package/src/providers/medusa/index.test.ts +40 -0
- package/src/providers/medusa/index.ts +173 -36
- package/src/providers/medusa/transforms.ts +21 -13
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Multi-Tenant Channel Scoping"
|
|
3
|
+
purpose: "Backend-agnostic pattern for isolating customers, orders, and categories per sales channel using metadata"
|
|
4
|
+
lastReviewed: "2026-09-28"
|
|
5
|
+
---
|
|
6
|
+
|
|
1
7
|
# Multi-Tenant Channel Scoping
|
|
2
8
|
|
|
3
9
|
> Backend-agnostic pattern for isolating entities by sales channel using metadata.
|
|
@@ -147,10 +153,42 @@ The pattern is generic. To scope a new entity type:
|
|
|
147
153
|
|-----------------|------------------------|------------------------|
|
|
148
154
|
| Products | ✅ `sales_channel_id` | N/A (native) |
|
|
149
155
|
| Carts | ✅ `sales_channel_id` | N/A (native) |
|
|
150
|
-
| Categories |
|
|
151
|
-
| **Customers** |
|
|
152
|
-
| **Orders** |
|
|
153
|
-
| Collections |
|
|
156
|
+
| Categories | ✅ via `sales_channel` link (`/store/thor/categories`) | N/A (native) |
|
|
157
|
+
| **Customers** | ⚠️ native `customer_sales_channel` link in backend | ✅ `metadata.sales_channel_id` (adapter filter) |
|
|
158
|
+
| **Orders** | ✅ inherits `sales_channel_id` from cart | ✅ `metadata.sales_channel_id` (adapter filter) |
|
|
159
|
+
| Collections | ✅ via `sales_channel` link (`/store/thor/collections`) | N/A (native) |
|
|
160
|
+
|
|
161
|
+
## Thor Commerce Plugin (Medusa) — Native Publishable-Key Scoping
|
|
162
|
+
|
|
163
|
+
When the backend runs the Thor Commerce plugin (see `medusa-backend`, `src/api/store/thor/*`),
|
|
164
|
+
scoping is resolved **server-side from the publishable API key** — no `sales_channel_id`
|
|
165
|
+
query param is ever required from the storefront:
|
|
166
|
+
|
|
167
|
+
- **Resolution:** Medusa core middleware maps `x-publishable-api-key` →
|
|
168
|
+
`sales_channel_ids`. A key bound to exactly one channel auto-injects that channel
|
|
169
|
+
into `/store/carts` bodies; a key bound to multiple channels requires the caller to
|
|
170
|
+
pass `sales_channel_id` explicitly.
|
|
171
|
+
- **Products / carts / orders:** natively scoped by `sales_channel_id` (orders inherit
|
|
172
|
+
the cart's channel; a checkout workflow hook validates every line against the cart channel).
|
|
173
|
+
- **Categories / collections:** scoped via their `sales_channel` M:M links. The adapter's
|
|
174
|
+
`getCategories` / `getCollections` call `GET /store/thor/categories` /
|
|
175
|
+
`GET /store/thor/collections` **unconditionally** — the endpoint resolves the channel
|
|
176
|
+
from the publishable key. The `salesChannelId` option (or
|
|
177
|
+
`NEXT_PUBLIC_SALES_CHANNEL_ID`) is only used for the legacy N+1 fallback when the
|
|
178
|
+
plugin endpoint is absent.
|
|
179
|
+
- **Payment methods:** there is no channel concept for payment in Medusa. `getPaymentMethods`
|
|
180
|
+
is scoped by **region** (+ currency) only.
|
|
181
|
+
- **createCart:** passes `sales_channel_id` into the payload when
|
|
182
|
+
`options.salesChannelId` or `NEXT_PUBLIC_SALES_CHANNEL_ID` is set (belt-and-suspenders —
|
|
183
|
+
the publishable key alone already scopes single-channel carts).
|
|
184
|
+
- **Customers:** the backend auto-links customers to the channel (middleware on
|
|
185
|
+
`/store/carts*` and `/store/customers/me*`). The adapter still filters
|
|
186
|
+
`getCustomers` via `metadata.sales_channel_id`; for backends with the plugin, prefer
|
|
187
|
+
`GET /store/thor/customers` for fully server-scoped reads.
|
|
188
|
+
|
|
189
|
+
> Note: backend-verified at Medusa v2.18.0 (`medusa-backend` repo). The plugin's
|
|
190
|
+
> `/store/thor/*` routes accept only `x-publishable-api-key`; they return `400` if the
|
|
191
|
+
> key resolves to no channel.
|
|
154
192
|
|
|
155
193
|
## When to Assign Channel Scope
|
|
156
194
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thorprovider/adapters",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.9",
|
|
4
4
|
"description": "Framework-agnostic commerce adapters for Thor Commerce ecosystem - Multi-platform support with unified API",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@medusajs/js-sdk": "2.16.0",
|
|
31
31
|
"@medusajs/types": "2.16.0",
|
|
32
|
-
"@thorprovider/types": "^5.3
|
|
32
|
+
"@thorprovider/types": "^5.6.3",
|
|
33
33
|
"bcryptjs": "^3.0.3",
|
|
34
34
|
"jose": "^6.2.2"
|
|
35
35
|
},
|
|
@@ -220,6 +220,46 @@ describe('MedusaProvider — Cart', () => {
|
|
|
220
220
|
expect(cart!.id).toBe('cart_01')
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
+
it('getCart transforms flat v2 cart items (no nested variant.product)', async () => {
|
|
224
|
+
const flatCart = {
|
|
225
|
+
...MEDUSA_CART,
|
|
226
|
+
items: [
|
|
227
|
+
{
|
|
228
|
+
id: 'cali_01',
|
|
229
|
+
variant_id: 'variant_01',
|
|
230
|
+
product_id: 'prod_01',
|
|
231
|
+
product_title: 'Cover Samsung Note 10 Plus',
|
|
232
|
+
product_handle: 'cover-samsung-note-10-plus',
|
|
233
|
+
variant_title: 'Cover Samsung Note 10 Plus - Negro',
|
|
234
|
+
title: 'Cover Samsung Note 10 Plus',
|
|
235
|
+
thumbnail: 'https://img.example.com/cover.png',
|
|
236
|
+
quantity: 2,
|
|
237
|
+
unit_price: 60,
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
total: 120,
|
|
241
|
+
subtotal: 120,
|
|
242
|
+
totalQuantity: 2,
|
|
243
|
+
}
|
|
244
|
+
server.use(
|
|
245
|
+
http.get(`${BASE}/store/carts/cart_01`, () =>
|
|
246
|
+
HttpResponse.json({ cart: flatCart }),
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const provider = makeProvider()
|
|
251
|
+
const cart = await provider.getCart('cart_01')
|
|
252
|
+
const line = cart?.lines?.[0]
|
|
253
|
+
|
|
254
|
+
expect(line?.merchandise.id).toBe('variant_01')
|
|
255
|
+
expect(line?.merchandise.title).toBe('Cover Samsung Note 10 Plus - Negro')
|
|
256
|
+
expect(line?.merchandise.product.id).toBe('prod_01')
|
|
257
|
+
expect(line?.merchandise.product.handle).toBe('cover-samsung-note-10-plus')
|
|
258
|
+
expect(line?.merchandise.product.title).toBe('Cover Samsung Note 10 Plus')
|
|
259
|
+
expect(line?.merchandise.product.featuredImage.url).toBe('https://img.example.com/cover.png')
|
|
260
|
+
expect(line?.cost.totalAmount.amount).toBe('120.00')
|
|
261
|
+
})
|
|
262
|
+
|
|
223
263
|
it('getCart returns undefined when cart not found (404)', async () => {
|
|
224
264
|
server.use(
|
|
225
265
|
http.get(`${BASE}/store/carts/cart_missing`, () =>
|
|
@@ -153,6 +153,7 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
153
153
|
private retryConfig: Required<RetryConfig>;
|
|
154
154
|
private baseUrl: string;
|
|
155
155
|
private publishableKey: string;
|
|
156
|
+
private regionId?: string;
|
|
156
157
|
|
|
157
158
|
/** Secret API key for admin operations (if configured). */
|
|
158
159
|
private adminApiKey?: string;
|
|
@@ -210,6 +211,7 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
210
211
|
this.currencyCode = config.currencyCode ?? 'USD';
|
|
211
212
|
this.baseUrl = config.baseUrl;
|
|
212
213
|
this.publishableKey = config.publishableKey;
|
|
214
|
+
this.regionId = config.regionId;
|
|
213
215
|
|
|
214
216
|
// Store retry configuration
|
|
215
217
|
this.retryConfig = {
|
|
@@ -389,10 +391,11 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
389
391
|
try {
|
|
390
392
|
return await this.withRetry(
|
|
391
393
|
async () => {
|
|
394
|
+
const regionId = options?.regionId ?? this.regionId;
|
|
392
395
|
// calculated_price requires region_id in Medusa v2 — only request it when available
|
|
393
396
|
const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
|
|
394
397
|
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
395
|
-
const fields =
|
|
398
|
+
const fields = regionId ? baseFields + pricingFields : baseFields;
|
|
396
399
|
|
|
397
400
|
const queryParams: any = {
|
|
398
401
|
handle,
|
|
@@ -400,8 +403,8 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
400
403
|
fields,
|
|
401
404
|
};
|
|
402
405
|
// Add region_id for region-based pricing
|
|
403
|
-
if (
|
|
404
|
-
queryParams.region_id =
|
|
406
|
+
if (regionId) {
|
|
407
|
+
queryParams.region_id = regionId;
|
|
405
408
|
}
|
|
406
409
|
|
|
407
410
|
// Scope to storefront sales channel when configured.
|
|
@@ -439,10 +442,11 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
439
442
|
try {
|
|
440
443
|
return await this.withRetry(
|
|
441
444
|
async () => {
|
|
445
|
+
const regionId = options.regionId ?? this.regionId;
|
|
442
446
|
// calculated_price requires region_id in Medusa v2 — only request it when available
|
|
443
447
|
const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
|
|
444
448
|
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
445
|
-
const fields =
|
|
449
|
+
const fields = regionId ? baseFields + pricingFields : baseFields;
|
|
446
450
|
|
|
447
451
|
// Map Thor SortOption values to Medusa /store/products `order` field names.
|
|
448
452
|
// Native endpoint supports: title, -title, created_at, -created_at
|
|
@@ -473,8 +477,8 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
473
477
|
}
|
|
474
478
|
|
|
475
479
|
// Add region_id for region-based pricing
|
|
476
|
-
if (
|
|
477
|
-
queryParams.region_id =
|
|
480
|
+
if (regionId) {
|
|
481
|
+
queryParams.region_id = regionId;
|
|
478
482
|
}
|
|
479
483
|
|
|
480
484
|
// Basic category/collection filtering
|
|
@@ -987,6 +991,18 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
987
991
|
|
|
988
992
|
let result = collections.map(transformCollection);
|
|
989
993
|
|
|
994
|
+
// --- Sales channel scoping ---
|
|
995
|
+
// Applied unconditionally: the Thor Commerce plugin endpoint resolves the
|
|
996
|
+
// sales channel from the publishable key itself. An explicit channel id
|
|
997
|
+
// (options.salesChannelId ?? env) is only needed for the legacy N+1
|
|
998
|
+
// fallback on backends without the plugin.
|
|
999
|
+
const salesChannelId =
|
|
1000
|
+
options?.salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
1001
|
+
const activeCollectionIds = await this._getChannelCollections(salesChannelId);
|
|
1002
|
+
if (activeCollectionIds) {
|
|
1003
|
+
result = result.filter((c: Collection) => activeCollectionIds.has(c.id));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
990
1006
|
// Client-side search filtering (Medusa Store API lacks query param for collections)
|
|
991
1007
|
if (options?.query) {
|
|
992
1008
|
const q = options.query.toLowerCase();
|
|
@@ -1007,6 +1023,100 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1007
1023
|
}
|
|
1008
1024
|
}
|
|
1009
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* Returns the set of product-collection IDs that have at least one product
|
|
1028
|
+
* available in the given sales channel (legacy fallback).
|
|
1029
|
+
*
|
|
1030
|
+
* @deprecated Use `_getChannelCollections()` instead (backed by Thor Commerce plugin).
|
|
1031
|
+
*/
|
|
1032
|
+
private async _getActiveCollectionIds(salesChannelId: string): Promise<Set<string>> {
|
|
1033
|
+
this.logger.debug('_getActiveCollectionIds', { salesChannelId });
|
|
1034
|
+
const collectionIds = new Set<string>();
|
|
1035
|
+
const limit = 500;
|
|
1036
|
+
let offset = 0;
|
|
1037
|
+
|
|
1038
|
+
// eslint-disable-next-line no-constant-condition
|
|
1039
|
+
while (true) {
|
|
1040
|
+
const { products } = (await this.sdk.store.product.list({
|
|
1041
|
+
sales_channel_id: [salesChannelId],
|
|
1042
|
+
fields: '+collections.id',
|
|
1043
|
+
limit,
|
|
1044
|
+
offset,
|
|
1045
|
+
} as any)) as any;
|
|
1046
|
+
|
|
1047
|
+
for (const product of products ?? []) {
|
|
1048
|
+
for (const col of product.collections ?? []) {
|
|
1049
|
+
if (col?.id) collectionIds.add(col.id);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (!products || products.length < limit) break;
|
|
1054
|
+
offset += limit;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
this.logger.debug('_getActiveCollectionIds result', { count: collectionIds.size });
|
|
1058
|
+
return collectionIds;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Fetch channel-scoped collections from Thor Commerce plugin.
|
|
1063
|
+
* Uses `GET /store/thor/collections` — the endpoint resolves the sales
|
|
1064
|
+
* channel from the publishable key header itself.
|
|
1065
|
+
*
|
|
1066
|
+
* Falls back to `_getActiveCollectionIds()` (legacy N+1) when the plugin is
|
|
1067
|
+
* not available; the legacy fallback needs an explicit `salesChannelId`.
|
|
1068
|
+
* Returns `null` when no scoping could be performed.
|
|
1069
|
+
*/
|
|
1070
|
+
private async _getChannelCollections(salesChannelId?: string): Promise<Set<string> | null> {
|
|
1071
|
+
try {
|
|
1072
|
+
const url = `${this.baseUrl}/store/thor/collections`;
|
|
1073
|
+
const response = await fetchWithRetry(
|
|
1074
|
+
url,
|
|
1075
|
+
{
|
|
1076
|
+
method: 'GET',
|
|
1077
|
+
headers: {
|
|
1078
|
+
'x-publishable-api-key': this.publishableKey,
|
|
1079
|
+
'Content-Type': 'application/json',
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1082
|
+
this.retryConfig,
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
if (response.status === 404 || !response.ok) {
|
|
1086
|
+
this.logger.debug('Thor plugin collections endpoint unavailable, falling back to legacy method');
|
|
1087
|
+
return this._legacyChannelCollections(salesChannelId);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const data = await response.json();
|
|
1091
|
+
const collectionIds = new Set<string>();
|
|
1092
|
+
for (const col of data.collections ?? []) {
|
|
1093
|
+
if (col?.id) collectionIds.add(col.id);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
this.logger.debug('_getChannelCollections result (Thor plugin)', { count: collectionIds.size });
|
|
1097
|
+
return collectionIds;
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
this.logger.warn('Failed to fetch channel collections from Thor plugin, falling back to legacy method', {
|
|
1100
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1101
|
+
});
|
|
1102
|
+
return this._legacyChannelCollections(salesChannelId);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Legacy fallback for `_getChannelCollections()` when the Thor plugin is not
|
|
1108
|
+
* installed. Requires an explicit `salesChannelId`; returns `null` otherwise.
|
|
1109
|
+
*/
|
|
1110
|
+
private async _legacyChannelCollections(salesChannelId?: string): Promise<Set<string> | null> {
|
|
1111
|
+
if (!salesChannelId) {
|
|
1112
|
+
this.logger.warn(
|
|
1113
|
+
'Cannot scope collections: Thor plugin unavailable and no sales channel id provided; returning unscoped collections',
|
|
1114
|
+
);
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
return this._getActiveCollectionIds(salesChannelId);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1010
1120
|
async getCollectionProducts(
|
|
1011
1121
|
handle: string,
|
|
1012
1122
|
options: CollectionProductsOptions = {},
|
|
@@ -1125,10 +1235,20 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1125
1235
|
|
|
1126
1236
|
/**
|
|
1127
1237
|
* Fetch channel-scoped categories from Thor Commerce plugin.
|
|
1128
|
-
* Uses `GET /store/thor/categories` — a single indexed query.
|
|
1129
|
-
*
|
|
1238
|
+
* Uses `GET /store/thor/categories` — a single indexed query. The endpoint
|
|
1239
|
+
* resolves the sales channel from the publishable key header itself, so no
|
|
1240
|
+
* explicit channel id is required when the plugin is installed.
|
|
1241
|
+
*
|
|
1242
|
+
* Falls back to `_getActiveCategoryIds()` (legacy N+1) when the plugin is
|
|
1243
|
+
* not available. The legacy fallback needs an explicit `salesChannelId`;
|
|
1244
|
+
* when none is provided and the plugin is unavailable, returns `null` to
|
|
1245
|
+
* signal that scoping is not possible and the caller should skip filtering.
|
|
1246
|
+
*
|
|
1247
|
+
* @param salesChannelId - Only used by the legacy fallback (plugin absent).
|
|
1248
|
+
* @returns Set of category IDs active in the channel, or `null` when no
|
|
1249
|
+
* scoping could be performed.
|
|
1130
1250
|
*/
|
|
1131
|
-
private async _getChannelCategories(salesChannelId
|
|
1251
|
+
private async _getChannelCategories(salesChannelId?: string): Promise<Set<string> | null> {
|
|
1132
1252
|
try {
|
|
1133
1253
|
const url = `${this.baseUrl}/store/thor/categories`;
|
|
1134
1254
|
const response = await fetchWithRetry(
|
|
@@ -1143,17 +1263,10 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1143
1263
|
this.retryConfig,
|
|
1144
1264
|
);
|
|
1145
1265
|
|
|
1146
|
-
if (response.status === 404) {
|
|
1266
|
+
if (response.status === 404 || !response.ok) {
|
|
1147
1267
|
// Plugin not installed — fall back to legacy N+1 method
|
|
1148
|
-
this.logger.debug('Thor plugin categories endpoint
|
|
1149
|
-
return this.
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
if (!response.ok) {
|
|
1153
|
-
this.logger.warn('Thor plugin categories endpoint returned non-OK status', {
|
|
1154
|
-
status: response.status,
|
|
1155
|
-
});
|
|
1156
|
-
return this._getActiveCategoryIds(salesChannelId);
|
|
1268
|
+
this.logger.debug('Thor plugin categories endpoint unavailable, falling back to legacy method');
|
|
1269
|
+
return this._legacyChannelCategories(salesChannelId);
|
|
1157
1270
|
}
|
|
1158
1271
|
|
|
1159
1272
|
const data = await response.json();
|
|
@@ -1168,10 +1281,24 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1168
1281
|
this.logger.warn('Failed to fetch channel categories from Thor plugin, falling back to legacy method', {
|
|
1169
1282
|
error: error instanceof Error ? error.message : String(error),
|
|
1170
1283
|
});
|
|
1171
|
-
return this.
|
|
1284
|
+
return this._legacyChannelCategories(salesChannelId);
|
|
1172
1285
|
}
|
|
1173
1286
|
}
|
|
1174
1287
|
|
|
1288
|
+
/**
|
|
1289
|
+
* Legacy fallback for `_getChannelCategories()` when the Thor plugin is not
|
|
1290
|
+
* installed. Requires an explicit `salesChannelId`; returns `null` otherwise.
|
|
1291
|
+
*/
|
|
1292
|
+
private async _legacyChannelCategories(salesChannelId?: string): Promise<Set<string> | null> {
|
|
1293
|
+
if (!salesChannelId) {
|
|
1294
|
+
this.logger.warn(
|
|
1295
|
+
'Cannot scope categories: Thor plugin unavailable and no sales channel id provided; returning unscoped categories',
|
|
1296
|
+
);
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
return this._getActiveCategoryIds(salesChannelId);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1175
1302
|
async getCategories(options: GetCategoriesOptions = {}): Promise<ProductCategory[]> {
|
|
1176
1303
|
this.logger.debug('getCategories', { options });
|
|
1177
1304
|
|
|
@@ -1202,12 +1329,14 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1202
1329
|
let result = product_categories.map(transformCategory);
|
|
1203
1330
|
|
|
1204
1331
|
// --- Sales channel scoping ---
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1332
|
+
// Applied unconditionally: the Thor Commerce plugin endpoint resolves the
|
|
1333
|
+
// sales channel from the publishable key itself. An explicit channel id
|
|
1334
|
+
// (options.salesChannelId ?? env) is only needed for the legacy N+1
|
|
1335
|
+
// fallback on backends without the plugin.
|
|
1207
1336
|
const salesChannelId =
|
|
1208
1337
|
options.salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
1209
|
-
|
|
1210
|
-
|
|
1338
|
+
const activeCategoryIds = await this._getChannelCategories(salesChannelId);
|
|
1339
|
+
if (activeCategoryIds) {
|
|
1211
1340
|
result = result.filter((c: ProductCategory) => activeCategoryIds.has(c.id));
|
|
1212
1341
|
}
|
|
1213
1342
|
|
|
@@ -1254,6 +1383,11 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1254
1383
|
'+variants,+variants.prices,+variants.calculated_prices,+variants.inventory_quantity,+options,+images,+tags',
|
|
1255
1384
|
};
|
|
1256
1385
|
|
|
1386
|
+
// Region-based pricing (calculated_price requires region_id in Medusa v2)
|
|
1387
|
+
if (this.regionId) {
|
|
1388
|
+
categoryQueryParams.region_id = this.regionId;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1257
1391
|
// Scope to storefront sales channel when configured.
|
|
1258
1392
|
const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
1259
1393
|
if (salesChannelId) {
|
|
@@ -1279,8 +1413,11 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1279
1413
|
// Cart Operations
|
|
1280
1414
|
// ============================================
|
|
1281
1415
|
|
|
1282
|
-
async createCart(options?: { regionId?: string }): Promise<Cart> {
|
|
1283
|
-
this.logger.debug('createCart', {
|
|
1416
|
+
async createCart(options?: { regionId?: string; salesChannelId?: string }): Promise<Cart> {
|
|
1417
|
+
this.logger.debug('createCart', {
|
|
1418
|
+
regionId: options?.regionId,
|
|
1419
|
+
salesChannelId: options?.salesChannelId,
|
|
1420
|
+
});
|
|
1284
1421
|
|
|
1285
1422
|
try {
|
|
1286
1423
|
const createPayload: any = {};
|
|
@@ -1294,6 +1431,16 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1294
1431
|
createPayload.currency_code = this.currencyCode.toLowerCase();
|
|
1295
1432
|
}
|
|
1296
1433
|
|
|
1434
|
+
// Explicit sales channel scope. Normally Medusa auto-injects the channel
|
|
1435
|
+
// from the publishable key when it maps to exactly one channel, but
|
|
1436
|
+
// passing it explicitly is required when the key maps to multiple
|
|
1437
|
+
// channels (otherwise cart creation errors).
|
|
1438
|
+
const salesChannelId =
|
|
1439
|
+
options?.salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
|
|
1440
|
+
if (salesChannelId) {
|
|
1441
|
+
createPayload.sales_channel_id = salesChannelId;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1297
1444
|
const { cart } = await this.sdk.store.cart.create(createPayload);
|
|
1298
1445
|
|
|
1299
1446
|
return transformCart(cart);
|
|
@@ -1347,16 +1494,12 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1347
1494
|
retrieveCtx,
|
|
1348
1495
|
);
|
|
1349
1496
|
|
|
1350
|
-
console.log('retrieveResult =', JSON.stringify(retrieveResult, null, 2))
|
|
1351
|
-
|
|
1352
1497
|
const backendCart = (retrieveResult as any)?.cart;
|
|
1353
1498
|
if (!backendCart) {
|
|
1354
1499
|
this.logger.warn('Cart not found before addToCart', { cartId });
|
|
1355
1500
|
throw new ProviderAPIError('Cart not found. Please refresh and try again.', 'medusa', 404);
|
|
1356
1501
|
}
|
|
1357
1502
|
|
|
1358
|
-
console.log('backendCart =', JSON.stringify(backendCart, null, 2))
|
|
1359
|
-
|
|
1360
1503
|
if (!backendCart.region_id) {
|
|
1361
1504
|
this.logger.warn('Attempted to add item to cart with no region_id', { cartId });
|
|
1362
1505
|
throw new ProviderAPIError(
|
|
@@ -1416,8 +1559,6 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1416
1559
|
);
|
|
1417
1560
|
}
|
|
1418
1561
|
|
|
1419
|
-
console.log('Found Variant:', JSON.stringify(foundVariant, null, 2));
|
|
1420
|
-
|
|
1421
1562
|
const variant = foundVariant;
|
|
1422
1563
|
// Prefer calculated_price when present
|
|
1423
1564
|
let hasPriceForRegion = false;
|
|
@@ -1432,10 +1573,6 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
1432
1573
|
''
|
|
1433
1574
|
).toLowerCase();
|
|
1434
1575
|
|
|
1435
|
-
console.log('calcCurrency =', calcCurrency);
|
|
1436
|
-
console.log('cartCurrency =', cartCurrency);
|
|
1437
|
-
console.log('this.currencyCode =', this.currencyCode)
|
|
1438
|
-
|
|
1439
1576
|
if (
|
|
1440
1577
|
!cartCurrency ||
|
|
1441
1578
|
calcCurrency === cartCurrency ||
|
|
@@ -291,7 +291,16 @@ export function transformCartItem(item: any, cartCurrencyCode?: string): CartIte
|
|
|
291
291
|
const product = variant?.product;
|
|
292
292
|
const currencyCode = resolveMedusaCartItemCurrencyCode(item, cartCurrencyCode);
|
|
293
293
|
const totalCents = resolveMedusaCartItemTotal(item);
|
|
294
|
-
|
|
294
|
+
|
|
295
|
+
// Medusa v2 store cart items return flat fields (product_title, variant_title,
|
|
296
|
+
// product_handle, thumbnail) instead of a nested variant.product object.
|
|
297
|
+
const merchandiseId = variant?.id ?? item.variant_id ?? '';
|
|
298
|
+
const merchandiseTitle = variant?.title ?? item.variant_title ?? '';
|
|
299
|
+
const productId = product?.id ?? item.product_id ?? '';
|
|
300
|
+
const productHandle = product?.handle ?? item.product_handle ?? '';
|
|
301
|
+
const productTitle = product?.title ?? item.product_title ?? '';
|
|
302
|
+
const thumbnail = product?.thumbnail ?? item.thumbnail ?? '';
|
|
303
|
+
|
|
295
304
|
return {
|
|
296
305
|
id: item.id,
|
|
297
306
|
quantity: item.quantity ?? 1,
|
|
@@ -299,19 +308,18 @@ export function transformCartItem(item: any, cartCurrencyCode?: string): CartIte
|
|
|
299
308
|
totalAmount: transformMoney(totalCents, currencyCode),
|
|
300
309
|
},
|
|
301
310
|
merchandise: {
|
|
302
|
-
id:
|
|
303
|
-
title:
|
|
304
|
-
selectedOptions:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
311
|
+
id: merchandiseId,
|
|
312
|
+
title: merchandiseTitle,
|
|
313
|
+
selectedOptions:
|
|
314
|
+
variant?.options?.map((opt: any) => ({
|
|
315
|
+
name: (opt as any).option?.title ?? '',
|
|
316
|
+
value: opt.value ?? '',
|
|
317
|
+
})) ?? [],
|
|
308
318
|
product: {
|
|
309
|
-
id:
|
|
310
|
-
handle:
|
|
311
|
-
title:
|
|
312
|
-
featuredImage:
|
|
313
|
-
? transformImage(product.thumbnail, product.title ?? '')
|
|
314
|
-
: transformImage('', ''),
|
|
319
|
+
id: productId,
|
|
320
|
+
handle: productHandle,
|
|
321
|
+
title: productTitle,
|
|
322
|
+
featuredImage: thumbnail ? transformImage(thumbnail, productTitle) : transformImage('', ''),
|
|
315
323
|
},
|
|
316
324
|
},
|
|
317
325
|
};
|