@thorprovider/adapters 3.0.2
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/.turbo/turbo-build.log +18 -0
- package/.turbo/turbo-test.log +1 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +271 -0
- package/README.md +634 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.mts +2524 -0
- package/dist/index.d.ts +2524 -0
- package/dist/index.js +8136 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +8045 -0
- package/dist/index.mjs.map +1 -0
- package/docs/RETRY_SOLUTION_SUMMARY.md +55 -0
- package/docs/authentication.md +165 -0
- package/docs/capabilities-system.md +143 -0
- package/docs/category-customization.md +309 -0
- package/docs/fetch-reliability-solution.md +399 -0
- package/docs/getting-started.md +154 -0
- package/docs/multi-tenant-channel-scoping.md +225 -0
- package/docs/profile-management.md +147 -0
- package/docs/providers-overview.md +85 -0
- package/docs/services-overview.md +135 -0
- package/docs/update-documentation.md +113 -0
- package/package.json +43 -0
- package/publish.log +93 -0
- package/quick-reference.md +169 -0
- package/src/admin/AdminAuthService.ts +88 -0
- package/src/admin/AdminSessionService.ts +91 -0
- package/src/admin/MetricsService.ts +145 -0
- package/src/admin/index.ts +13 -0
- package/src/backend/BackendMetadataAdapter.ts +88 -0
- package/src/backend/factory.ts +107 -0
- package/src/backend/index.ts +12 -0
- package/src/backend/medusa/MedusaMetadataAdapter.ts +139 -0
- package/src/core/errors.js +161 -0
- package/src/core/errors.ts +132 -0
- package/src/core/fetch-utils.js +240 -0
- package/src/core/fetch-utils.ts +227 -0
- package/src/core/index.ts +8 -0
- package/src/core/logger.js +63 -0
- package/src/core/logger.ts +37 -0
- package/src/factory/index.ts +102 -0
- package/src/index.ts +205 -0
- package/src/lib/money-transform.ts +80 -0
- package/src/metadata/channel-scope-validation.js +136 -0
- package/src/metadata/channel-scope-validation.ts +151 -0
- package/src/metadata/channel-scope.js +174 -0
- package/src/metadata/channel-scope.ts +215 -0
- package/src/metadata/index.js +31 -0
- package/src/metadata/index.ts +37 -0
- package/src/providers/bigcommerce/bigcommerce.ts +6 -0
- package/src/providers/bigcommerce/index.ts +315 -0
- package/src/providers/bigcommerce/transforms.ts +227 -0
- package/src/providers/index.ts +22 -0
- package/src/providers/magento/index.ts +387 -0
- package/src/providers/magento/magento.ts +5 -0
- package/src/providers/magento/queries.ts +267 -0
- package/src/providers/magento/transforms.ts +249 -0
- package/src/providers/medusa/auth.js +601 -0
- package/src/providers/medusa/auth.ts +496 -0
- package/src/providers/medusa/fulfillment-planner.ts +225 -0
- package/src/providers/medusa/index.js +2485 -0
- package/src/providers/medusa/index.ts +4239 -0
- package/src/providers/medusa/medusa.ts +8 -0
- package/src/providers/medusa/payment.js +265 -0
- package/src/providers/medusa/payment.ts +269 -0
- package/src/providers/medusa/transforms.js +505 -0
- package/src/providers/medusa/transforms.ts +625 -0
- package/src/providers/shopify/index.ts +444 -0
- package/src/providers/shopify/mutations.ts +102 -0
- package/src/providers/shopify/queries.ts +324 -0
- package/src/providers/shopify/shopify.ts +7 -0
- package/src/providers/shopify/transforms.ts +270 -0
- package/src/providers/spree/index.ts +344 -0
- package/src/providers/spree/spree.ts +5 -0
- package/src/providers/spree/transforms.ts +254 -0
- package/src/providers/woocommerce/index.ts +310 -0
- package/src/providers/woocommerce/transforms.ts +254 -0
- package/src/providers/woocommerce/woocommerce.ts +5 -0
- package/src/resilience/README.md +299 -0
- package/src/resilience/circuit-breaker.ts +222 -0
- package/src/resilience/index.ts +10 -0
- package/src/types/capabilities.types.js +141 -0
- package/src/types/capabilities.types.ts +366 -0
- package/src/types/index.ts +24 -0
- package/src/types/provider.js +9 -0
- package/src/types/provider.ts +9 -0
- package/src/types/ui-responses.ts +261 -0
- package/src/utils/city-coordinates.ts +40 -0
- package/src/utils/geolocation.ts +52 -0
- package/src/utils/payment-utils.js +152 -0
- package/src/utils/payment-utils.ts +203 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +14 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Scope Validation — Pure TypeScript (no external deps)
|
|
3
|
+
*
|
|
4
|
+
* Validates channel-scope metadata before it is written to the backend.
|
|
5
|
+
* Keeps all validation logic in L2 so that L5 starters don't duplicate checks.
|
|
6
|
+
*
|
|
7
|
+
* @module metadata/channel-scope-validation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChannelScopeMetadata, ChannelScopeable } from './channel-scope';
|
|
11
|
+
import { CHANNEL_SCOPE_KEY } from './channel-scope';
|
|
12
|
+
|
|
13
|
+
// ────────────────────────────────────────────────────────────────────
|
|
14
|
+
// Errors
|
|
15
|
+
// ────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Thrown when channel-scope validation fails.
|
|
19
|
+
*
|
|
20
|
+
* Consumers can catch this specific type to provide user-friendly feedback
|
|
21
|
+
* without leaking internal details.
|
|
22
|
+
*/
|
|
23
|
+
export class ChannelScopeValidationError extends Error {
|
|
24
|
+
public readonly field: string;
|
|
25
|
+
public readonly value: unknown;
|
|
26
|
+
|
|
27
|
+
constructor(message: string, field: string, value?: unknown) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'ChannelScopeValidationError';
|
|
30
|
+
this.field = field;
|
|
31
|
+
this.value = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Validation functions
|
|
37
|
+
// ────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate that a channel ID is a non-empty string.
|
|
41
|
+
*
|
|
42
|
+
* Does **not** enforce a specific prefix (e.g. `"sc_"`) because different
|
|
43
|
+
* backends use different conventions:
|
|
44
|
+
* - Medusa: `"sc_01HXXX"`
|
|
45
|
+
* - Shopify: `"gid://shopify/Channel/789"`
|
|
46
|
+
* - WooCommerce: `"1"` (numeric string)
|
|
47
|
+
*
|
|
48
|
+
* @throws {ChannelScopeValidationError}
|
|
49
|
+
*/
|
|
50
|
+
export function validateChannelId(channelId: unknown): asserts channelId is string {
|
|
51
|
+
if (typeof channelId !== 'string' || channelId.trim().length === 0) {
|
|
52
|
+
throw new ChannelScopeValidationError(
|
|
53
|
+
'Channel ID must be a non-empty string',
|
|
54
|
+
'channelId',
|
|
55
|
+
channelId,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Sanity upper-bound for ID length (guards against payload abuse)
|
|
60
|
+
if (channelId.length > 256) {
|
|
61
|
+
throw new ChannelScopeValidationError(
|
|
62
|
+
'Channel ID exceeds maximum length (256)',
|
|
63
|
+
'channelId',
|
|
64
|
+
channelId,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate that an entity ID is a non-empty string.
|
|
71
|
+
*
|
|
72
|
+
* @throws {ChannelScopeValidationError}
|
|
73
|
+
*/
|
|
74
|
+
export function validateEntityId(entityId: unknown, label = 'entityId'): asserts entityId is string {
|
|
75
|
+
if (typeof entityId !== 'string' || entityId.trim().length === 0) {
|
|
76
|
+
throw new ChannelScopeValidationError(
|
|
77
|
+
`${label} must be a non-empty string`,
|
|
78
|
+
label,
|
|
79
|
+
entityId,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate that the `assignedBy` value (if provided) is a reasonable string.
|
|
86
|
+
*
|
|
87
|
+
* @throws {ChannelScopeValidationError}
|
|
88
|
+
*/
|
|
89
|
+
export function validateAssignedBy(assignedBy: unknown): asserts assignedBy is string | undefined {
|
|
90
|
+
if (assignedBy === undefined || assignedBy === null) return;
|
|
91
|
+
if (typeof assignedBy !== 'string') {
|
|
92
|
+
throw new ChannelScopeValidationError(
|
|
93
|
+
'assignedBy must be a string',
|
|
94
|
+
'assignedBy',
|
|
95
|
+
assignedBy,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (assignedBy.length > 512) {
|
|
99
|
+
throw new ChannelScopeValidationError(
|
|
100
|
+
'assignedBy exceeds maximum length (512)',
|
|
101
|
+
'assignedBy',
|
|
102
|
+
assignedBy,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Full validation for a channel-scope assignment operation.
|
|
109
|
+
*
|
|
110
|
+
* Call before writing channel scope metadata to any entity.
|
|
111
|
+
*
|
|
112
|
+
* @throws {ChannelScopeValidationError} with descriptive field + value
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```typescript
|
|
116
|
+
* validateChannelScopeAssignment('cus_123', 'sc_01HXXX', 'admin@store.com');
|
|
117
|
+
* // If no error, safe to proceed
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export function validateChannelScopeAssignment(
|
|
121
|
+
entityId: string,
|
|
122
|
+
channelId: string,
|
|
123
|
+
assignedBy?: string,
|
|
124
|
+
): void {
|
|
125
|
+
validateEntityId(entityId, 'entityId');
|
|
126
|
+
validateChannelId(channelId);
|
|
127
|
+
validateAssignedBy(assignedBy);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validate that an entity is currently scoped to the expected channel.
|
|
132
|
+
*
|
|
133
|
+
* Useful as a guard before performing operations that should only
|
|
134
|
+
* affect entities within the caller's channel (e.g. a dropshipper editing
|
|
135
|
+
* a customer that must belong to their channel).
|
|
136
|
+
*
|
|
137
|
+
* @throws {ChannelScopeValidationError} if entity does not belong to expectedChannelId
|
|
138
|
+
*/
|
|
139
|
+
export function assertEntityBelongsToChannel(
|
|
140
|
+
entity: ChannelScopeable,
|
|
141
|
+
expectedChannelId: string,
|
|
142
|
+
): void {
|
|
143
|
+
const actual = entity.metadata?.[CHANNEL_SCOPE_KEY];
|
|
144
|
+
if (actual !== expectedChannelId) {
|
|
145
|
+
throw new ChannelScopeValidationError(
|
|
146
|
+
`Entity "${entity.id}" does not belong to channel "${expectedChannelId}" (actual: "${actual ?? 'none'}")`,
|
|
147
|
+
CHANNEL_SCOPE_KEY,
|
|
148
|
+
actual,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Channel Scope — Backend-Agnostic Multi-Tenant Helpers
|
|
4
|
+
*
|
|
5
|
+
* Provides utilities for tagging, reading and filtering entity metadata by
|
|
6
|
+
* sales-channel (or any future tenant key). Works with any backend that
|
|
7
|
+
* exposes a writable `metadata: Record<string, unknown>` field on its
|
|
8
|
+
* entities (Medusa, Shopify metafields, WooCommerce meta, etc.).
|
|
9
|
+
*
|
|
10
|
+
* ## Design decisions
|
|
11
|
+
*
|
|
12
|
+
* | Decision | Rationale |
|
|
13
|
+
* |----------|-----------|
|
|
14
|
+
* | Flat key (`sales_channel_id`) | Simpler queries, no prefix parsing required |
|
|
15
|
+
* | Metadata, not custom fields | Zero DB migrations; works across backends |
|
|
16
|
+
* | Pure functions | No side effects → easily testable; usable in any layer |
|
|
17
|
+
* | `assigned_at` timestamp | Audit trail for dropshipper / admin assignment |
|
|
18
|
+
*
|
|
19
|
+
* @module metadata/channel-scope
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.CHANNEL_ASSIGNED_BY_KEY = exports.CHANNEL_ASSIGNED_AT_KEY = exports.CHANNEL_SCOPE_KEY = void 0;
|
|
23
|
+
exports.buildChannelScopeMetadata = buildChannelScopeMetadata;
|
|
24
|
+
exports.getChannelScope = getChannelScope;
|
|
25
|
+
exports.isScopedToChannel = isScopedToChannel;
|
|
26
|
+
exports.isGlobalEntity = isGlobalEntity;
|
|
27
|
+
exports.filterByChannel = filterByChannel;
|
|
28
|
+
exports.filterGlobalOnly = filterGlobalOnly;
|
|
29
|
+
exports.partitionByChannel = partitionByChannel;
|
|
30
|
+
// ────────────────────────────────────────────────────────────────────
|
|
31
|
+
// Constants
|
|
32
|
+
// ────────────────────────────────────────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Well-known metadata key used to store the owning sales-channel on any
|
|
35
|
+
* entity that Medusa (or the backend) does not natively scope.
|
|
36
|
+
*
|
|
37
|
+
* Convention: every entity whose metadata contains this key is
|
|
38
|
+
* considered "owned" by the referenced channel. Entities without it
|
|
39
|
+
* are global / unscoped.
|
|
40
|
+
*/
|
|
41
|
+
exports.CHANNEL_SCOPE_KEY = 'sales_channel_id';
|
|
42
|
+
/**
|
|
43
|
+
* Optional audit key — ISO-8601 timestamp of when the channel was assigned.
|
|
44
|
+
*/
|
|
45
|
+
exports.CHANNEL_ASSIGNED_AT_KEY = 'channel_assigned_at';
|
|
46
|
+
/**
|
|
47
|
+
* Optional audit key — who assigned the entity to the channel.
|
|
48
|
+
* Value is a free-form string (admin email, user id, "system", etc.).
|
|
49
|
+
*/
|
|
50
|
+
exports.CHANNEL_ASSIGNED_BY_KEY = 'channel_assigned_by';
|
|
51
|
+
// ────────────────────────────────────────────────────────────────────
|
|
52
|
+
// Builders
|
|
53
|
+
// ────────────────────────────────────────────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* Build channel-scope metadata payload.
|
|
56
|
+
*
|
|
57
|
+
* The returned object should be **merged** into the entity's existing
|
|
58
|
+
* `metadata` field when calling `updateCustomer`, `updateOrder`, etc.
|
|
59
|
+
*
|
|
60
|
+
* @param channelId - Sales-channel ID (e.g. `"sc_01HXXX"`)
|
|
61
|
+
* @param assignedBy - Optional identifier of who performed the assignment
|
|
62
|
+
* @returns Metadata fragment ready to spread into an update payload
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* const scope = buildChannelScopeMetadata('sc_01HXXX', 'admin@store.com');
|
|
67
|
+
* await commerce.updateCustomer(customerId, {
|
|
68
|
+
* metadata: { ...existingMeta, ...scope },
|
|
69
|
+
* });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
function buildChannelScopeMetadata(channelId, assignedBy) {
|
|
73
|
+
var _a;
|
|
74
|
+
var payload = (_a = {},
|
|
75
|
+
_a[exports.CHANNEL_SCOPE_KEY] = channelId,
|
|
76
|
+
_a[exports.CHANNEL_ASSIGNED_AT_KEY] = new Date().toISOString(),
|
|
77
|
+
_a);
|
|
78
|
+
if (assignedBy) {
|
|
79
|
+
payload[exports.CHANNEL_ASSIGNED_BY_KEY] = assignedBy;
|
|
80
|
+
}
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
// ────────────────────────────────────────────────────────────────────
|
|
84
|
+
// Readers
|
|
85
|
+
// ────────────────────────────────────────────────────────────────────
|
|
86
|
+
/**
|
|
87
|
+
* Read the channel-scope value from an entity's metadata.
|
|
88
|
+
*
|
|
89
|
+
* @returns The channel ID string, or `null` if the entity has no scope set.
|
|
90
|
+
*/
|
|
91
|
+
function getChannelScope(entity) {
|
|
92
|
+
var _a;
|
|
93
|
+
var value = (_a = entity.metadata) === null || _a === void 0 ? void 0 : _a[exports.CHANNEL_SCOPE_KEY];
|
|
94
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Check if an entity is scoped to a specific channel.
|
|
98
|
+
*
|
|
99
|
+
* @param entity - Any entity with optional `metadata`
|
|
100
|
+
* @param channelId - Channel ID to match against
|
|
101
|
+
* @returns `true` if entity's metadata contains a matching scope key
|
|
102
|
+
*/
|
|
103
|
+
function isScopedToChannel(entity, channelId) {
|
|
104
|
+
return getChannelScope(entity) === channelId;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Check if an entity has **no** channel scope (i.e. it is "global").
|
|
108
|
+
*/
|
|
109
|
+
function isGlobalEntity(entity) {
|
|
110
|
+
return getChannelScope(entity) === null;
|
|
111
|
+
}
|
|
112
|
+
// ────────────────────────────────────────────────────────────────────
|
|
113
|
+
// Filters
|
|
114
|
+
// ────────────────────────────────────────────────────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* Filter an array of entities to only those belonging to a channel.
|
|
117
|
+
*
|
|
118
|
+
* If `channelId` is `undefined` or empty, returns the full array unfiltered.
|
|
119
|
+
* This preserves backward compatibility for admin views that intentionally
|
|
120
|
+
* list all entities regardless of tenant.
|
|
121
|
+
*
|
|
122
|
+
* @param entities - Array of channel-scopeable entities
|
|
123
|
+
* @param channelId - Optional channel ID to filter by
|
|
124
|
+
* @returns Filtered (or unfiltered) array
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```typescript
|
|
128
|
+
* const allCustomers = await commerce.getCustomers();
|
|
129
|
+
* const myCustomers = filterByChannel(allCustomers, salesChannelId);
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
function filterByChannel(entities, channelId) {
|
|
133
|
+
if (!channelId)
|
|
134
|
+
return entities;
|
|
135
|
+
return entities.filter(function (e) { return isScopedToChannel(e, channelId); });
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Filter an array of entities to only those that are global (unscoped).
|
|
139
|
+
*
|
|
140
|
+
* Useful for admin views that want to list entities not yet assigned to
|
|
141
|
+
* any channel.
|
|
142
|
+
*/
|
|
143
|
+
function filterGlobalOnly(entities) {
|
|
144
|
+
return entities.filter(isGlobalEntity);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Partition entities into `{ scoped, global }` groups.
|
|
148
|
+
*
|
|
149
|
+
* @param entities - Array of channel-scopeable entities
|
|
150
|
+
* @param channelId - Channel ID to partition against
|
|
151
|
+
* @returns Object with `scoped` (matching channel) and `global` (no scope) arrays
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```typescript
|
|
155
|
+
* const { scoped, global } = partitionByChannel(customers, 'sc_123');
|
|
156
|
+
* // scoped → customers assigned to sc_123
|
|
157
|
+
* // global → customers not assigned to any channel
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
function partitionByChannel(entities, channelId) {
|
|
161
|
+
var scoped = [];
|
|
162
|
+
var global = [];
|
|
163
|
+
for (var _i = 0, entities_1 = entities; _i < entities_1.length; _i++) {
|
|
164
|
+
var entity = entities_1[_i];
|
|
165
|
+
if (isScopedToChannel(entity, channelId)) {
|
|
166
|
+
scoped.push(entity);
|
|
167
|
+
}
|
|
168
|
+
else if (isGlobalEntity(entity)) {
|
|
169
|
+
global.push(entity);
|
|
170
|
+
}
|
|
171
|
+
// Entities scoped to a *different* channel are silently excluded.
|
|
172
|
+
}
|
|
173
|
+
return { scoped: scoped, global: global };
|
|
174
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Scope — Backend-Agnostic Multi-Tenant Helpers
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for tagging, reading and filtering entity metadata by
|
|
5
|
+
* sales-channel (or any future tenant key). Works with any backend that
|
|
6
|
+
* exposes a writable `metadata: Record<string, unknown>` field on its
|
|
7
|
+
* entities (Medusa, Shopify metafields, WooCommerce meta, etc.).
|
|
8
|
+
*
|
|
9
|
+
* ## Design decisions
|
|
10
|
+
*
|
|
11
|
+
* | Decision | Rationale |
|
|
12
|
+
* |----------|-----------|
|
|
13
|
+
* | Flat key (`sales_channel_id`) | Simpler queries, no prefix parsing required |
|
|
14
|
+
* | Metadata, not custom fields | Zero DB migrations; works across backends |
|
|
15
|
+
* | Pure functions | No side effects → easily testable; usable in any layer |
|
|
16
|
+
* | `assigned_at` timestamp | Audit trail for dropshipper / admin assignment |
|
|
17
|
+
*
|
|
18
|
+
* @module metadata/channel-scope
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Constants
|
|
23
|
+
// ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Well-known metadata key used to store the owning sales-channel on any
|
|
27
|
+
* entity that Medusa (or the backend) does not natively scope.
|
|
28
|
+
*
|
|
29
|
+
* Convention: every entity whose metadata contains this key is
|
|
30
|
+
* considered "owned" by the referenced channel. Entities without it
|
|
31
|
+
* are global / unscoped.
|
|
32
|
+
*/
|
|
33
|
+
export const CHANNEL_SCOPE_KEY = 'sales_channel_id' as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Optional audit key — ISO-8601 timestamp of when the channel was assigned.
|
|
37
|
+
*/
|
|
38
|
+
export const CHANNEL_ASSIGNED_AT_KEY = 'channel_assigned_at' as const;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Optional audit key — who assigned the entity to the channel.
|
|
42
|
+
* Value is a free-form string (admin email, user id, "system", etc.).
|
|
43
|
+
*/
|
|
44
|
+
export const CHANNEL_ASSIGNED_BY_KEY = 'channel_assigned_by' as const;
|
|
45
|
+
|
|
46
|
+
// ────────────────────────────────────────────────────────────────────
|
|
47
|
+
// Types
|
|
48
|
+
// ────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Minimal shape any entity must satisfy in order to participate in
|
|
52
|
+
* channel-scope filtering. Compatible with @thorprovider/types Customer,
|
|
53
|
+
* Order, ProductCategory, etc.
|
|
54
|
+
*/
|
|
55
|
+
export interface ChannelScopeable {
|
|
56
|
+
id: string;
|
|
57
|
+
metadata?: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Payload produced by `buildChannelScopeMetadata()`.
|
|
62
|
+
* Can be spread directly into an `updateCustomer` / `updateOrder` call.
|
|
63
|
+
*/
|
|
64
|
+
export interface ChannelScopeMetadata {
|
|
65
|
+
[CHANNEL_SCOPE_KEY]: string;
|
|
66
|
+
[CHANNEL_ASSIGNED_AT_KEY]: string;
|
|
67
|
+
[CHANNEL_ASSIGNED_BY_KEY]?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ────────────────────────────────────────────────────────────────────
|
|
71
|
+
// Builders
|
|
72
|
+
// ────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build channel-scope metadata payload.
|
|
76
|
+
*
|
|
77
|
+
* The returned object should be **merged** into the entity's existing
|
|
78
|
+
* `metadata` field when calling `updateCustomer`, `updateOrder`, etc.
|
|
79
|
+
*
|
|
80
|
+
* @param channelId - Sales-channel ID (e.g. `"sc_01HXXX"`)
|
|
81
|
+
* @param assignedBy - Optional identifier of who performed the assignment
|
|
82
|
+
* @returns Metadata fragment ready to spread into an update payload
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```typescript
|
|
86
|
+
* const scope = buildChannelScopeMetadata('sc_01HXXX', 'admin@store.com');
|
|
87
|
+
* await commerce.updateCustomer(customerId, {
|
|
88
|
+
* metadata: { ...existingMeta, ...scope },
|
|
89
|
+
* });
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function buildChannelScopeMetadata(
|
|
93
|
+
channelId: string,
|
|
94
|
+
assignedBy?: string,
|
|
95
|
+
): ChannelScopeMetadata {
|
|
96
|
+
const payload: ChannelScopeMetadata = {
|
|
97
|
+
[CHANNEL_SCOPE_KEY]: channelId,
|
|
98
|
+
[CHANNEL_ASSIGNED_AT_KEY]: new Date().toISOString(),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (assignedBy) {
|
|
102
|
+
payload[CHANNEL_ASSIGNED_BY_KEY] = assignedBy;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return payload;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ────────────────────────────────────────────────────────────────────
|
|
109
|
+
// Readers
|
|
110
|
+
// ────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read the channel-scope value from an entity's metadata.
|
|
114
|
+
*
|
|
115
|
+
* @returns The channel ID string, or `null` if the entity has no scope set.
|
|
116
|
+
*/
|
|
117
|
+
export function getChannelScope(entity: ChannelScopeable): string | null {
|
|
118
|
+
const value = entity.metadata?.[CHANNEL_SCOPE_KEY];
|
|
119
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if an entity is scoped to a specific channel.
|
|
124
|
+
*
|
|
125
|
+
* @param entity - Any entity with optional `metadata`
|
|
126
|
+
* @param channelId - Channel ID to match against
|
|
127
|
+
* @returns `true` if entity's metadata contains a matching scope key
|
|
128
|
+
*/
|
|
129
|
+
export function isScopedToChannel(
|
|
130
|
+
entity: ChannelScopeable,
|
|
131
|
+
channelId: string,
|
|
132
|
+
): boolean {
|
|
133
|
+
return getChannelScope(entity) === channelId;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if an entity has **no** channel scope (i.e. it is "global").
|
|
138
|
+
*/
|
|
139
|
+
export function isGlobalEntity(entity: ChannelScopeable): boolean {
|
|
140
|
+
return getChannelScope(entity) === null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ────────────────────────────────────────────────────────────────────
|
|
144
|
+
// Filters
|
|
145
|
+
// ────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Filter an array of entities to only those belonging to a channel.
|
|
149
|
+
*
|
|
150
|
+
* If `channelId` is `undefined` or empty, returns the full array unfiltered.
|
|
151
|
+
* This preserves backward compatibility for admin views that intentionally
|
|
152
|
+
* list all entities regardless of tenant.
|
|
153
|
+
*
|
|
154
|
+
* @param entities - Array of channel-scopeable entities
|
|
155
|
+
* @param channelId - Optional channel ID to filter by
|
|
156
|
+
* @returns Filtered (or unfiltered) array
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```typescript
|
|
160
|
+
* const allCustomers = await commerce.getCustomers();
|
|
161
|
+
* const myCustomers = filterByChannel(allCustomers, salesChannelId);
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export function filterByChannel<T extends ChannelScopeable>(
|
|
165
|
+
entities: T[],
|
|
166
|
+
channelId?: string,
|
|
167
|
+
): T[] {
|
|
168
|
+
if (!channelId) return entities;
|
|
169
|
+
return entities.filter((e) => isScopedToChannel(e, channelId));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Filter an array of entities to only those that are global (unscoped).
|
|
174
|
+
*
|
|
175
|
+
* Useful for admin views that want to list entities not yet assigned to
|
|
176
|
+
* any channel.
|
|
177
|
+
*/
|
|
178
|
+
export function filterGlobalOnly<T extends ChannelScopeable>(
|
|
179
|
+
entities: T[],
|
|
180
|
+
): T[] {
|
|
181
|
+
return entities.filter(isGlobalEntity);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Partition entities into `{ scoped, global }` groups.
|
|
186
|
+
*
|
|
187
|
+
* @param entities - Array of channel-scopeable entities
|
|
188
|
+
* @param channelId - Channel ID to partition against
|
|
189
|
+
* @returns Object with `scoped` (matching channel) and `global` (no scope) arrays
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```typescript
|
|
193
|
+
* const { scoped, global } = partitionByChannel(customers, 'sc_123');
|
|
194
|
+
* // scoped → customers assigned to sc_123
|
|
195
|
+
* // global → customers not assigned to any channel
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
export function partitionByChannel<T extends ChannelScopeable>(
|
|
199
|
+
entities: T[],
|
|
200
|
+
channelId: string,
|
|
201
|
+
): { scoped: T[]; global: T[] } {
|
|
202
|
+
const scoped: T[] = [];
|
|
203
|
+
const global: T[] = [];
|
|
204
|
+
|
|
205
|
+
for (const entity of entities) {
|
|
206
|
+
if (isScopedToChannel(entity, channelId)) {
|
|
207
|
+
scoped.push(entity);
|
|
208
|
+
} else if (isGlobalEntity(entity)) {
|
|
209
|
+
global.push(entity);
|
|
210
|
+
}
|
|
211
|
+
// Entities scoped to a *different* channel are silently excluded.
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { scoped, global };
|
|
215
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Metadata — Channel Scope Utilities
|
|
4
|
+
*
|
|
5
|
+
* Backend-agnostic helpers for multi-tenant channel scoping.
|
|
6
|
+
* Used by L2 adapters to tag, validate and filter entities by sales channel.
|
|
7
|
+
*
|
|
8
|
+
* @module metadata
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.assertEntityBelongsToChannel = exports.validateChannelScopeAssignment = exports.validateAssignedBy = exports.validateEntityId = exports.validateChannelId = exports.ChannelScopeValidationError = exports.partitionByChannel = exports.filterGlobalOnly = exports.filterByChannel = exports.isGlobalEntity = exports.isScopedToChannel = exports.getChannelScope = exports.buildChannelScopeMetadata = exports.CHANNEL_ASSIGNED_BY_KEY = exports.CHANNEL_ASSIGNED_AT_KEY = exports.CHANNEL_SCOPE_KEY = void 0;
|
|
12
|
+
// Builders, readers & filters
|
|
13
|
+
var channel_scope_1 = require("./channel-scope");
|
|
14
|
+
Object.defineProperty(exports, "CHANNEL_SCOPE_KEY", { enumerable: true, get: function () { return channel_scope_1.CHANNEL_SCOPE_KEY; } });
|
|
15
|
+
Object.defineProperty(exports, "CHANNEL_ASSIGNED_AT_KEY", { enumerable: true, get: function () { return channel_scope_1.CHANNEL_ASSIGNED_AT_KEY; } });
|
|
16
|
+
Object.defineProperty(exports, "CHANNEL_ASSIGNED_BY_KEY", { enumerable: true, get: function () { return channel_scope_1.CHANNEL_ASSIGNED_BY_KEY; } });
|
|
17
|
+
Object.defineProperty(exports, "buildChannelScopeMetadata", { enumerable: true, get: function () { return channel_scope_1.buildChannelScopeMetadata; } });
|
|
18
|
+
Object.defineProperty(exports, "getChannelScope", { enumerable: true, get: function () { return channel_scope_1.getChannelScope; } });
|
|
19
|
+
Object.defineProperty(exports, "isScopedToChannel", { enumerable: true, get: function () { return channel_scope_1.isScopedToChannel; } });
|
|
20
|
+
Object.defineProperty(exports, "isGlobalEntity", { enumerable: true, get: function () { return channel_scope_1.isGlobalEntity; } });
|
|
21
|
+
Object.defineProperty(exports, "filterByChannel", { enumerable: true, get: function () { return channel_scope_1.filterByChannel; } });
|
|
22
|
+
Object.defineProperty(exports, "filterGlobalOnly", { enumerable: true, get: function () { return channel_scope_1.filterGlobalOnly; } });
|
|
23
|
+
Object.defineProperty(exports, "partitionByChannel", { enumerable: true, get: function () { return channel_scope_1.partitionByChannel; } });
|
|
24
|
+
// Validation
|
|
25
|
+
var channel_scope_validation_1 = require("./channel-scope-validation");
|
|
26
|
+
Object.defineProperty(exports, "ChannelScopeValidationError", { enumerable: true, get: function () { return channel_scope_validation_1.ChannelScopeValidationError; } });
|
|
27
|
+
Object.defineProperty(exports, "validateChannelId", { enumerable: true, get: function () { return channel_scope_validation_1.validateChannelId; } });
|
|
28
|
+
Object.defineProperty(exports, "validateEntityId", { enumerable: true, get: function () { return channel_scope_validation_1.validateEntityId; } });
|
|
29
|
+
Object.defineProperty(exports, "validateAssignedBy", { enumerable: true, get: function () { return channel_scope_validation_1.validateAssignedBy; } });
|
|
30
|
+
Object.defineProperty(exports, "validateChannelScopeAssignment", { enumerable: true, get: function () { return channel_scope_validation_1.validateChannelScopeAssignment; } });
|
|
31
|
+
Object.defineProperty(exports, "assertEntityBelongsToChannel", { enumerable: true, get: function () { return channel_scope_validation_1.assertEntityBelongsToChannel; } });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata — Channel Scope Utilities
|
|
3
|
+
*
|
|
4
|
+
* Backend-agnostic helpers for multi-tenant channel scoping.
|
|
5
|
+
* Used by L2 adapters to tag, validate and filter entities by sales channel.
|
|
6
|
+
*
|
|
7
|
+
* @module metadata
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Builders, readers & filters
|
|
11
|
+
export {
|
|
12
|
+
CHANNEL_SCOPE_KEY,
|
|
13
|
+
CHANNEL_ASSIGNED_AT_KEY,
|
|
14
|
+
CHANNEL_ASSIGNED_BY_KEY,
|
|
15
|
+
buildChannelScopeMetadata,
|
|
16
|
+
getChannelScope,
|
|
17
|
+
isScopedToChannel,
|
|
18
|
+
isGlobalEntity,
|
|
19
|
+
filterByChannel,
|
|
20
|
+
filterGlobalOnly,
|
|
21
|
+
partitionByChannel,
|
|
22
|
+
} from './channel-scope';
|
|
23
|
+
|
|
24
|
+
export type {
|
|
25
|
+
ChannelScopeable,
|
|
26
|
+
ChannelScopeMetadata,
|
|
27
|
+
} from './channel-scope';
|
|
28
|
+
|
|
29
|
+
// Validation
|
|
30
|
+
export {
|
|
31
|
+
ChannelScopeValidationError,
|
|
32
|
+
validateChannelId,
|
|
33
|
+
validateEntityId,
|
|
34
|
+
validateAssignedBy,
|
|
35
|
+
validateChannelScopeAssignment,
|
|
36
|
+
assertEntityBelongsToChannel,
|
|
37
|
+
} from './channel-scope-validation';
|