@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,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Medusa Authentication Provider
|
|
3
|
+
* @module @thorprovider/adapters/providers/medusa/auth
|
|
4
|
+
*
|
|
5
|
+
* Implementation of AuthProvider for Medusa.js v2
|
|
6
|
+
* Supports JWT and session-based authentication
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Medusa from '@medusajs/js-sdk';
|
|
10
|
+
import type {
|
|
11
|
+
AuthProvider,
|
|
12
|
+
AuthConfig,
|
|
13
|
+
LoginCredentials,
|
|
14
|
+
RegisterData,
|
|
15
|
+
AuthResponse,
|
|
16
|
+
Customer,
|
|
17
|
+
Address,
|
|
18
|
+
UpdateCustomerData,
|
|
19
|
+
CreateAddressData,
|
|
20
|
+
PasswordResetRequest,
|
|
21
|
+
PasswordResetConfirm,
|
|
22
|
+
} from '@thorprovider/types';
|
|
23
|
+
import { createLogger } from '../../core/logger';
|
|
24
|
+
import { ProviderAPIError } from '../../core/errors';
|
|
25
|
+
import type { Logger } from '../../core/logger';
|
|
26
|
+
import { transformCustomer, transformAddress } from './transforms';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Storage manager for authentication tokens
|
|
30
|
+
*/
|
|
31
|
+
class AuthStorageManager {
|
|
32
|
+
private storage: AuthConfig['customStorage'] | null = null;
|
|
33
|
+
private storageType: AuthConfig['storage'];
|
|
34
|
+
private tokenKey = 'thor_auth_token';
|
|
35
|
+
|
|
36
|
+
constructor(config: AuthConfig) {
|
|
37
|
+
this.storageType = config.storage;
|
|
38
|
+
this.storage = config.customStorage ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async getToken(): Promise<string | null> {
|
|
42
|
+
if (this.storage) {
|
|
43
|
+
return this.storage.getItem(this.tokenKey);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof window === 'undefined') {
|
|
47
|
+
// Server-side: no storage available
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
switch (this.storageType) {
|
|
52
|
+
case 'localStorage':
|
|
53
|
+
return window.localStorage.getItem(this.tokenKey);
|
|
54
|
+
case 'sessionStorage':
|
|
55
|
+
return window.sessionStorage.getItem(this.tokenKey);
|
|
56
|
+
case 'cookie':
|
|
57
|
+
// Cookies are handled automatically by fetch, just return null
|
|
58
|
+
return null;
|
|
59
|
+
case 'memory':
|
|
60
|
+
return null;
|
|
61
|
+
default:
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async setToken(token: string): Promise<void> {
|
|
67
|
+
if (this.storage) {
|
|
68
|
+
await this.storage.setItem(this.tokenKey, token);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof window === 'undefined') {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
switch (this.storageType) {
|
|
77
|
+
case 'localStorage':
|
|
78
|
+
window.localStorage.setItem(this.tokenKey, token);
|
|
79
|
+
break;
|
|
80
|
+
case 'sessionStorage':
|
|
81
|
+
window.sessionStorage.setItem(this.tokenKey, token);
|
|
82
|
+
break;
|
|
83
|
+
case 'cookie':
|
|
84
|
+
case 'memory':
|
|
85
|
+
// No action needed for cookie (handled by SDK) or memory
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async removeToken(): Promise<void> {
|
|
91
|
+
if (this.storage) {
|
|
92
|
+
await this.storage.removeItem(this.tokenKey);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof window === 'undefined') {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
switch (this.storageType) {
|
|
101
|
+
case 'localStorage':
|
|
102
|
+
window.localStorage.removeItem(this.tokenKey);
|
|
103
|
+
break;
|
|
104
|
+
case 'sessionStorage':
|
|
105
|
+
window.sessionStorage.removeItem(this.tokenKey);
|
|
106
|
+
break;
|
|
107
|
+
case 'cookie':
|
|
108
|
+
case 'memory':
|
|
109
|
+
// No action needed
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Medusa authentication provider implementation
|
|
117
|
+
*/
|
|
118
|
+
export class MedusaAuthProvider implements AuthProvider {
|
|
119
|
+
private sdk: Medusa;
|
|
120
|
+
private logger: ReturnType<typeof createLogger>;
|
|
121
|
+
private authConfig: AuthConfig;
|
|
122
|
+
private storageManager: AuthStorageManager;
|
|
123
|
+
|
|
124
|
+
constructor(sdk: Medusa, authConfig: AuthConfig, debug = false) {
|
|
125
|
+
this.sdk = sdk;
|
|
126
|
+
this.authConfig = authConfig;
|
|
127
|
+
this.logger = createLogger('MedusaAuthProvider', debug);
|
|
128
|
+
this.storageManager = new AuthStorageManager(authConfig);
|
|
129
|
+
|
|
130
|
+
this.logger.info('Medusa Auth Provider initialized', {
|
|
131
|
+
method: authConfig.method,
|
|
132
|
+
storage: authConfig.storage,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ========================================
|
|
137
|
+
// Authentication Methods
|
|
138
|
+
// ========================================
|
|
139
|
+
|
|
140
|
+
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
|
141
|
+
this.logger.debug('login', { email: credentials.email });
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// Step 1: Authenticate with Medusa
|
|
145
|
+
const token = await this.sdk.auth.login('customer', 'emailpass', {
|
|
146
|
+
email: credentials.email,
|
|
147
|
+
password: credentials.password,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Check if token requires additional actions (OAuth flow)
|
|
151
|
+
if (typeof token !== 'string') {
|
|
152
|
+
this.logger.warn('Authentication requires additional actions', token);
|
|
153
|
+
return {
|
|
154
|
+
requiresAction: true,
|
|
155
|
+
redirectUrl: token.location,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Step 2: Store token based on auth method
|
|
160
|
+
if (this.authConfig.method === 'jwt') {
|
|
161
|
+
await this.storageManager.setToken(token);
|
|
162
|
+
}
|
|
163
|
+
// Note: For session-based auth, the SDK automatically handles cookies
|
|
164
|
+
// when auth.login() is called with auth.type='session' in SDK config
|
|
165
|
+
|
|
166
|
+
// Step 3: Fetch customer data
|
|
167
|
+
const customer = await this.getCurrentCustomer();
|
|
168
|
+
|
|
169
|
+
this.logger.info('Login successful', { customerId: customer?.id });
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
token: this.authConfig.method === 'jwt' ? token : undefined,
|
|
173
|
+
customer: customer ?? undefined,
|
|
174
|
+
};
|
|
175
|
+
} catch (error: any) {
|
|
176
|
+
this.logger.error('Login failed', error);
|
|
177
|
+
throw new ProviderAPIError(
|
|
178
|
+
`Authentication failed: ${error.message}`,
|
|
179
|
+
'LOGIN_FAILED',
|
|
180
|
+
error
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async register(data: RegisterData): Promise<AuthResponse> {
|
|
186
|
+
this.logger.debug('register', { email: data.email });
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// Step 1: Register auth identity with Medusa
|
|
190
|
+
const token = await this.sdk.auth.register('customer', 'emailpass', {
|
|
191
|
+
email: data.email,
|
|
192
|
+
password: data.password,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (typeof token !== 'string') {
|
|
196
|
+
throw new Error('Registration returned non-string token');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Step 2: Create customer profile
|
|
200
|
+
// Note: The SDK automatically includes the token in the header after auth.register()
|
|
201
|
+
const { customer } = await this.sdk.store.customer.create({
|
|
202
|
+
email: data.email,
|
|
203
|
+
first_name: data.first_name,
|
|
204
|
+
last_name: data.last_name,
|
|
205
|
+
phone: data.phone,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Step 3: Store token based on auth method
|
|
209
|
+
if (this.authConfig.method === 'jwt') {
|
|
210
|
+
await this.storageManager.setToken(token);
|
|
211
|
+
}
|
|
212
|
+
// Note: For session-based auth, the SDK automatically handles cookies
|
|
213
|
+
// when auth.register() is called with auth.type='session' in SDK config
|
|
214
|
+
|
|
215
|
+
this.logger.info('Registration successful', { customerId: customer.id });
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
token: this.authConfig.method === 'jwt' ? token : undefined,
|
|
219
|
+
customer: transformCustomer(customer, customer.email),
|
|
220
|
+
};
|
|
221
|
+
} catch (error: any) {
|
|
222
|
+
this.logger.error('Registration failed', error);
|
|
223
|
+
throw new ProviderAPIError(
|
|
224
|
+
`Registration failed: ${error.message}`,
|
|
225
|
+
'REGISTRATION_FAILED',
|
|
226
|
+
error
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async logout(): Promise<void> {
|
|
232
|
+
this.logger.debug('logout');
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
await this.storageManager.removeToken();
|
|
236
|
+
|
|
237
|
+
// For session auth, also call logout endpoint
|
|
238
|
+
if (this.authConfig.method === 'session') {
|
|
239
|
+
await this.sdk.auth.logout();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.logger.info('Logout successful');
|
|
243
|
+
} catch (error: any) {
|
|
244
|
+
this.logger.error('Logout failed', error);
|
|
245
|
+
// Don't throw error on logout failure, just log it
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ========================================
|
|
250
|
+
// Password Management
|
|
251
|
+
// ========================================
|
|
252
|
+
|
|
253
|
+
async requestPasswordReset(email: string): Promise<void> {
|
|
254
|
+
this.logger.debug('requestPasswordReset', { email });
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await this.sdk.auth.resetPassword('customer', 'emailpass', {
|
|
258
|
+
identifier: email,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
this.logger.info('Password reset email sent', { email });
|
|
262
|
+
} catch (error: any) {
|
|
263
|
+
this.logger.error('Password reset request failed', error);
|
|
264
|
+
throw new ProviderAPIError(
|
|
265
|
+
`Password reset request failed: ${error.message}`,
|
|
266
|
+
'PASSWORD_RESET_FAILED',
|
|
267
|
+
error
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async resetPassword(
|
|
273
|
+
token: string,
|
|
274
|
+
password: string,
|
|
275
|
+
metadata?: Record<string, unknown>
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
this.logger.debug('resetPassword', { hasMetadata: !!metadata });
|
|
278
|
+
|
|
279
|
+
// Medusa requires email in the request body
|
|
280
|
+
// Extract from metadata or decode from token
|
|
281
|
+
const email = metadata?.email as string | undefined;
|
|
282
|
+
|
|
283
|
+
if (!email) {
|
|
284
|
+
throw new ProviderAPIError(
|
|
285
|
+
'Email is required in metadata for Medusa password reset',
|
|
286
|
+
'MEDUSA_AUTH_PROVIDER',
|
|
287
|
+
400
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
// Medusa v2.6+ API: email + password in body, token as 3rd param
|
|
293
|
+
await this.sdk.auth.updateProvider('customer', 'emailpass', {
|
|
294
|
+
email,
|
|
295
|
+
password,
|
|
296
|
+
}, token);
|
|
297
|
+
|
|
298
|
+
this.logger.info('Password reset successful');
|
|
299
|
+
} catch (error: any) {
|
|
300
|
+
this.logger.error('Password reset failed', error);
|
|
301
|
+
throw new ProviderAPIError(
|
|
302
|
+
`Password reset failed: ${error.message}`,
|
|
303
|
+
'PASSWORD_RESET_CONFIRM_FAILED',
|
|
304
|
+
error
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ========================================
|
|
310
|
+
// Session Management
|
|
311
|
+
// ========================================
|
|
312
|
+
|
|
313
|
+
async getCurrentCustomer(): Promise<Customer | null> {
|
|
314
|
+
this.logger.debug('getCurrentCustomer');
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const { customer } = await this.sdk.store.customer.retrieve();
|
|
318
|
+
|
|
319
|
+
if (!customer) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return transformCustomer(customer, customer.email);
|
|
324
|
+
} catch (error: any) {
|
|
325
|
+
this.logger.debug('No authenticated customer');
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async updateCustomer(data: UpdateCustomerData): Promise<Customer> {
|
|
331
|
+
this.logger.debug('updateCustomer');
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const updateData = { ...data };
|
|
335
|
+
if (updateData.phone === null) {
|
|
336
|
+
delete updateData.phone; // Medusa doesn't accept null, only undefined
|
|
337
|
+
}
|
|
338
|
+
const { customer } = await this.sdk.store.customer.update(updateData as any);
|
|
339
|
+
|
|
340
|
+
return transformCustomer(customer, customer.email);
|
|
341
|
+
} catch (error: any) {
|
|
342
|
+
this.logger.error('Update customer failed', error);
|
|
343
|
+
throw new ProviderAPIError(
|
|
344
|
+
`Failed to update customer: ${error.message}`,
|
|
345
|
+
'UPDATE_CUSTOMER_FAILED',
|
|
346
|
+
error
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async isAuthenticated(): Promise<boolean> {
|
|
352
|
+
const customer = await this.getCurrentCustomer();
|
|
353
|
+
return customer !== null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
getAuthToken(): string | null {
|
|
357
|
+
// For JWT auth, return stored token
|
|
358
|
+
if (this.authConfig.method === 'jwt') {
|
|
359
|
+
// This is sync, so we can't use async storage
|
|
360
|
+
// Return null and rely on storage manager
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// For session auth, return null (cookies are handled automatically)
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ========================================
|
|
369
|
+
// Address Management
|
|
370
|
+
// ========================================
|
|
371
|
+
|
|
372
|
+
async getAddresses(): Promise<Address[]> {
|
|
373
|
+
this.logger.debug('getAddresses');
|
|
374
|
+
try {
|
|
375
|
+
const { addresses } = await this.sdk.store.customer.listAddress();
|
|
376
|
+
return addresses.map((address: any) => transformAddress(address));
|
|
377
|
+
} catch (error: any) {
|
|
378
|
+
this.logger.error('Get addresses failed', error);
|
|
379
|
+
throw new ProviderAPIError(
|
|
380
|
+
`Failed to get addresses: ${error.message}`,
|
|
381
|
+
'GET_ADDRESSES_FAILED',
|
|
382
|
+
error
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async createAddress(data: CreateAddressData): Promise<Address> {
|
|
388
|
+
this.logger.debug('createAddress');
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
// Transform camelCase to snake_case for Medusa API
|
|
392
|
+
const medusaData = {
|
|
393
|
+
first_name: data.firstName,
|
|
394
|
+
last_name: data.lastName,
|
|
395
|
+
company: data.company,
|
|
396
|
+
address_1: data.address1,
|
|
397
|
+
address_2: data.address2,
|
|
398
|
+
city: data.city,
|
|
399
|
+
province: data.province,
|
|
400
|
+
postal_code: data.postalCode,
|
|
401
|
+
country_code: data.countryCode,
|
|
402
|
+
phone: data.phone,
|
|
403
|
+
metadata: data.metadata,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const { customer } = await this.sdk.store.customer.createAddress(medusaData);
|
|
407
|
+
|
|
408
|
+
// Get the most recent address (last one in array)
|
|
409
|
+
const addresses = customer.addresses || [];
|
|
410
|
+
const address = addresses[addresses.length - 1];
|
|
411
|
+
|
|
412
|
+
if (!address) {
|
|
413
|
+
throw new Error('No address returned from createAddress');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return transformAddress(address);
|
|
417
|
+
} catch (error: any) {
|
|
418
|
+
this.logger.error('Create address failed', error);
|
|
419
|
+
throw new ProviderAPIError(
|
|
420
|
+
`Failed to create address: ${error.message}`,
|
|
421
|
+
'CREATE_ADDRESS_FAILED',
|
|
422
|
+
error
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async updateAddress(
|
|
428
|
+
addressId: string,
|
|
429
|
+
data: Partial<CreateAddressData>
|
|
430
|
+
): Promise<Address> {
|
|
431
|
+
this.logger.debug('updateAddress', { addressId });
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
// Transform camelCase to snake_case for Medusa API (partial)
|
|
435
|
+
const medusaData: any = {};
|
|
436
|
+
if (data.firstName !== undefined) medusaData.first_name = data.firstName;
|
|
437
|
+
if (data.lastName !== undefined) medusaData.last_name = data.lastName;
|
|
438
|
+
if (data.company !== undefined) medusaData.company = data.company;
|
|
439
|
+
if (data.address1 !== undefined) medusaData.address_1 = data.address1;
|
|
440
|
+
if (data.address2 !== undefined) medusaData.address_2 = data.address2;
|
|
441
|
+
if (data.city !== undefined) medusaData.city = data.city;
|
|
442
|
+
if (data.province !== undefined) medusaData.province = data.province;
|
|
443
|
+
if (data.postalCode !== undefined) medusaData.postal_code = data.postalCode;
|
|
444
|
+
if (data.countryCode !== undefined) medusaData.country_code = data.countryCode;
|
|
445
|
+
if (data.phone !== undefined) medusaData.phone = data.phone;
|
|
446
|
+
if (data.metadata !== undefined) medusaData.metadata = data.metadata;
|
|
447
|
+
|
|
448
|
+
const { customer } = await this.sdk.store.customer.updateAddress(addressId, medusaData);
|
|
449
|
+
|
|
450
|
+
// Find the updated address by ID
|
|
451
|
+
const addresses = customer.addresses || [];
|
|
452
|
+
const address = addresses.find((addr: any) => addr.id === addressId);
|
|
453
|
+
|
|
454
|
+
if (!address) {
|
|
455
|
+
throw new Error(`Address ${addressId} not found after update`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return transformAddress(address);
|
|
459
|
+
} catch (error: any) {
|
|
460
|
+
this.logger.error('Update address failed', error);
|
|
461
|
+
throw new ProviderAPIError(
|
|
462
|
+
`Failed to update address: ${error.message}`,
|
|
463
|
+
'UPDATE_ADDRESS_FAILED',
|
|
464
|
+
error
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async deleteAddress(addressId: string): Promise<void> {
|
|
470
|
+
this.logger.debug('deleteAddress', { addressId });
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
await this.sdk.store.customer.deleteAddress(addressId);
|
|
474
|
+
this.logger.info('Address deleted', { addressId });
|
|
475
|
+
} catch (error: any) {
|
|
476
|
+
this.logger.error('Delete address failed', error);
|
|
477
|
+
throw new ProviderAPIError(
|
|
478
|
+
`Failed to delete address: ${error.message}`,
|
|
479
|
+
'DELETE_ADDRESS_FAILED',
|
|
480
|
+
error
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async deleteCustomerAccount(): Promise<void> {
|
|
486
|
+
this.logger.debug('deleteCustomerAccount');
|
|
487
|
+
|
|
488
|
+
// TODO: Implement when custom Medusa backend endpoint is ready
|
|
489
|
+
// DELETE /store/customers/me
|
|
490
|
+
throw new ProviderAPIError(
|
|
491
|
+
'Delete customer account is not yet implemented. Requires custom Medusa backend endpoint.',
|
|
492
|
+
'NOT_IMPLEMENTED',
|
|
493
|
+
undefined
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fulfillment planner
|
|
3
|
+
*
|
|
4
|
+
* Plans an optimal fulfillment strategy for cart items given:
|
|
5
|
+
* - available stock locations
|
|
6
|
+
* - inventory levels per variant
|
|
7
|
+
* - an optional shipping address (for proximity sorting)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CartItem, InventoryLevel, StockLocation, FulfillmentOption } from '@thorprovider/types';
|
|
11
|
+
import { calculateDistance, geocodeAddress, type Coordinates } from '../../utils/geolocation';
|
|
12
|
+
|
|
13
|
+
export interface FulfillmentGroup {
|
|
14
|
+
locationId: string;
|
|
15
|
+
locationName: string;
|
|
16
|
+
items: Array<{
|
|
17
|
+
variantId: string;
|
|
18
|
+
quantity: number;
|
|
19
|
+
productName: string;
|
|
20
|
+
}>;
|
|
21
|
+
shippingOptions: FulfillmentOption[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FulfillmentPlan {
|
|
25
|
+
canFulfill: boolean;
|
|
26
|
+
groups: FulfillmentGroup[];
|
|
27
|
+
shippingAddress?: Coordinates;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PlanFulfillmentShippingAddress {
|
|
31
|
+
city?: string;
|
|
32
|
+
countryCode?: string;
|
|
33
|
+
postalCode?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface InventoryMap {
|
|
37
|
+
[variantId: string]: InventoryLevel[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function planFulfillment(
|
|
41
|
+
cartItems: CartItem[],
|
|
42
|
+
stockLocations: StockLocation[],
|
|
43
|
+
inventoryLevels: InventoryMap,
|
|
44
|
+
shippingAddress?: PlanFulfillmentShippingAddress,
|
|
45
|
+
): Promise<FulfillmentPlan> {
|
|
46
|
+
if (cartItems.length === 0) {
|
|
47
|
+
return { canFulfill: true, groups: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let addressCoords: Coordinates | null = null;
|
|
51
|
+
if (shippingAddress) {
|
|
52
|
+
addressCoords = await geocodeAddress(shippingAddress);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const itemLocationMap = new Map<string, string[]>();
|
|
56
|
+
|
|
57
|
+
for (const item of cartItems) {
|
|
58
|
+
const variantId = item.merchandise.id;
|
|
59
|
+
const levels = inventoryLevels[variantId] ?? [];
|
|
60
|
+
|
|
61
|
+
const availableLocations = levels
|
|
62
|
+
.filter((level) => level.availableQuantity >= item.quantity)
|
|
63
|
+
.map((level) => level.locationId);
|
|
64
|
+
|
|
65
|
+
itemLocationMap.set(variantId, availableLocations);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const singleLocationPlan = findSingleLocationFulfillment(
|
|
69
|
+
cartItems,
|
|
70
|
+
stockLocations,
|
|
71
|
+
itemLocationMap,
|
|
72
|
+
addressCoords,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (singleLocationPlan) {
|
|
76
|
+
return {
|
|
77
|
+
canFulfill: true,
|
|
78
|
+
groups: [singleLocationPlan],
|
|
79
|
+
shippingAddress: addressCoords ?? undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const splitPlan = findSplitFulfillment(cartItems, stockLocations, inventoryLevels, addressCoords);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
canFulfill: splitPlan.groups.length > 0,
|
|
87
|
+
groups: splitPlan.groups,
|
|
88
|
+
shippingAddress: addressCoords ?? undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findSingleLocationFulfillment(
|
|
93
|
+
items: CartItem[],
|
|
94
|
+
locations: StockLocation[],
|
|
95
|
+
itemLocationMap: Map<string, string[]>,
|
|
96
|
+
shippingCoords: Coordinates | null,
|
|
97
|
+
): FulfillmentGroup | null {
|
|
98
|
+
const firstVariantId = items[0]?.merchandise?.id;
|
|
99
|
+
if (!firstVariantId) return null;
|
|
100
|
+
|
|
101
|
+
const firstItemLocations = itemLocationMap.get(firstVariantId);
|
|
102
|
+
if (!firstItemLocations || firstItemLocations.length === 0) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const locationCandidates = new Set<string>(firstItemLocations);
|
|
107
|
+
|
|
108
|
+
for (let i = 1; i < items.length; i++) {
|
|
109
|
+
const variantId = items[i]?.merchandise?.id;
|
|
110
|
+
if (!variantId) return null;
|
|
111
|
+
|
|
112
|
+
const itemLocations = itemLocationMap.get(variantId) ?? [];
|
|
113
|
+
const itemLocationsSet = new Set(itemLocations);
|
|
114
|
+
|
|
115
|
+
for (const loc of locationCandidates) {
|
|
116
|
+
if (!itemLocationsSet.has(loc)) {
|
|
117
|
+
locationCandidates.delete(loc);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (locationCandidates.size === 0) return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let selectedLocationId = Array.from(locationCandidates)[0];
|
|
125
|
+
|
|
126
|
+
if (shippingCoords) {
|
|
127
|
+
let minDistance = Infinity;
|
|
128
|
+
|
|
129
|
+
for (const locId of locationCandidates) {
|
|
130
|
+
const location = locations.find((l) => l.id === locId);
|
|
131
|
+
const lat = location?.metadata?.lat;
|
|
132
|
+
const lng = location?.metadata?.lng;
|
|
133
|
+
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
|
|
134
|
+
|
|
135
|
+
const distance = calculateDistance(shippingCoords, { lat, lng });
|
|
136
|
+
if (distance < minDistance) {
|
|
137
|
+
minDistance = distance;
|
|
138
|
+
selectedLocationId = locId;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const selectedLocation = locations.find((l) => l.id === selectedLocationId);
|
|
144
|
+
if (!selectedLocation) return null;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
locationId: selectedLocationId,
|
|
148
|
+
locationName: selectedLocation.name,
|
|
149
|
+
items: items.map((item) => ({
|
|
150
|
+
variantId: item.merchandise.id,
|
|
151
|
+
quantity: item.quantity,
|
|
152
|
+
productName: item.merchandise.product.title,
|
|
153
|
+
})),
|
|
154
|
+
shippingOptions: [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function findSplitFulfillment(
|
|
159
|
+
items: CartItem[],
|
|
160
|
+
locations: StockLocation[],
|
|
161
|
+
inventoryLevels: InventoryMap,
|
|
162
|
+
shippingCoords: Coordinates | null,
|
|
163
|
+
): { groups: FulfillmentGroup[] } {
|
|
164
|
+
const groups: FulfillmentGroup[] = [];
|
|
165
|
+
const assignedVariantIds = new Set<string>();
|
|
166
|
+
|
|
167
|
+
const sortedLocations = sortLocationsByProximity(locations, shippingCoords);
|
|
168
|
+
|
|
169
|
+
for (const location of sortedLocations) {
|
|
170
|
+
const groupItems: FulfillmentGroup['items'] = [];
|
|
171
|
+
|
|
172
|
+
for (const item of items) {
|
|
173
|
+
const variantId = item.merchandise.id;
|
|
174
|
+
if (assignedVariantIds.has(variantId)) continue;
|
|
175
|
+
|
|
176
|
+
const levels = inventoryLevels[variantId] ?? [];
|
|
177
|
+
const levelAtLocation = levels.find((l) => l.locationId === location.id);
|
|
178
|
+
|
|
179
|
+
if (levelAtLocation && levelAtLocation.availableQuantity >= item.quantity) {
|
|
180
|
+
groupItems.push({
|
|
181
|
+
variantId,
|
|
182
|
+
quantity: item.quantity,
|
|
183
|
+
productName: item.merchandise.product.title,
|
|
184
|
+
});
|
|
185
|
+
assignedVariantIds.add(variantId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (groupItems.length > 0) {
|
|
190
|
+
groups.push({
|
|
191
|
+
locationId: location.id,
|
|
192
|
+
locationName: location.name,
|
|
193
|
+
items: groupItems,
|
|
194
|
+
shippingOptions: [],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (assignedVariantIds.size === items.length) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { groups };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sortLocationsByProximity(
|
|
207
|
+
locations: StockLocation[],
|
|
208
|
+
shippingCoords: Coordinates | null,
|
|
209
|
+
): StockLocation[] {
|
|
210
|
+
if (!shippingCoords) return [...locations];
|
|
211
|
+
|
|
212
|
+
return [...locations].sort((a, b) => {
|
|
213
|
+
const distA =
|
|
214
|
+
typeof a.metadata?.lat === 'number' && typeof a.metadata?.lng === 'number'
|
|
215
|
+
? calculateDistance(shippingCoords, { lat: a.metadata.lat, lng: a.metadata.lng })
|
|
216
|
+
: Infinity;
|
|
217
|
+
|
|
218
|
+
const distB =
|
|
219
|
+
typeof b.metadata?.lat === 'number' && typeof b.metadata?.lng === 'number'
|
|
220
|
+
? calculateDistance(shippingCoords, { lat: b.metadata.lat, lng: b.metadata.lng })
|
|
221
|
+
: Infinity;
|
|
222
|
+
|
|
223
|
+
return distA - distB;
|
|
224
|
+
});
|
|
225
|
+
}
|