ebay-mcp 1.14.3 → 1.16.0
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/README.de.md +23 -15
- package/README.es.md +23 -15
- package/README.fr.md +23 -15
- package/README.ja.md +23 -15
- package/README.ko.md +23 -15
- package/README.md +108 -14
- package/README.pt-BR.md +23 -15
- package/README.ru.md +23 -15
- package/README.zh-CN.md +23 -15
- package/build/api/client.js +88 -12
- package/build/api/index.js +12 -0
- package/build/api/listing-management/items.js +4 -1
- package/build/api/listing-management/media.js +335 -0
- package/build/api/listing-management/mediaAttach.js +145 -0
- package/build/api/listing-management/offerFormat.js +111 -0
- package/build/api/listing-management/offers.js +82 -23
- package/build/api/other/browse.js +120 -0
- package/build/api/other/browseFilter.js +74 -0
- package/build/api/other/browseMappers.js +205 -0
- package/build/api/other/browseTypes.js +8 -0
- package/build/api/other/browseValidators.js +180 -0
- package/build/api/shared/auctionPricing.js +44 -0
- package/build/api/shared/request.js +2 -2
- package/build/api/trading/listingFormat.js +179 -0
- package/build/api/trading/trading.js +68 -18
- package/build/auth/credentialSession.js +2 -3
- package/build/auth/oauth.js +26 -8
- package/build/auth/scopeUtils.js +5 -0
- package/build/config/credentialFile.js +16 -0
- package/build/config/environment.js +72 -4
- package/build/config/mediaAccess.js +51 -0
- package/build/mcp/runtime.js +13 -5
- package/build/schemas/inventory-management/inventory.js +102 -26
- package/build/schemas/inventory-management/media.js +106 -0
- package/build/schemas/other/browse.js +65 -0
- package/build/skills/content.js +13 -6
- package/build/tools/categories/account.js +1 -1
- package/build/tools/categories/browse.js +15 -0
- package/build/tools/categories/index.js +2 -1
- package/build/tools/categories/inventory.js +31 -14
- package/build/tools/categories/media.js +76 -0
- package/build/tools/categories/taxonomy.js +2 -2
- package/build/tools/categories/trading.js +8 -7
- package/build/tools/schemas.js +5 -2
- package/build/tools/ui/maps.js +21 -2
- package/build/types/ebayEnums.js +29 -0
- package/build/utils/errors.js +84 -0
- package/build/utils/http.js +61 -46
- package/build/utils/localMedia.js +180 -0
- package/build/utils/objects.js +26 -0
- package/build/utils/trading/trading.js +14 -2
- package/package.json +2 -2
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { EbayApiError, EndpointInputError, requireObjectEffect, requireStringEffect, } from '../../api/shared/request.js';
|
|
2
|
+
import { getMediaBaseUrl } from '../../config/environment.js';
|
|
3
|
+
import { getErrorMessage } from '../../utils/errors.js';
|
|
4
|
+
import { Data, Effect } from 'effect';
|
|
5
|
+
import { createMediaAttachMethods } from './mediaAttach.js';
|
|
6
|
+
/** Media API base path; the host is `apim` rather than `api` (see {@link getMediaBaseUrl}). */
|
|
7
|
+
export const MEDIA_BASE_PATH = '/commerce/media/v1_beta';
|
|
8
|
+
/** Timeout for binary uploads, which can be far larger than JSON calls. */
|
|
9
|
+
const UPLOAD_TIMEOUT_MS = 10 * 60_000;
|
|
10
|
+
/** How long {@link MediaApi.waitForVideo} waits for eBay processing by default. */
|
|
11
|
+
export const DEFAULT_VIDEO_WAIT_MS = 120_000;
|
|
12
|
+
/** Interval between video status polls. */
|
|
13
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
14
|
+
/** Video statuses eBay treats as final. */
|
|
15
|
+
export const TERMINAL_VIDEO_STATUSES = new Set([
|
|
16
|
+
'LIVE',
|
|
17
|
+
'BLOCKED',
|
|
18
|
+
'PROCESSING_FAILED',
|
|
19
|
+
]);
|
|
20
|
+
/** Failure of one upload in a sequence, carrying what already succeeded. */
|
|
21
|
+
export class MediaUploadError extends Data.TaggedError('MediaUploadError') {
|
|
22
|
+
}
|
|
23
|
+
const idFromLocation = (location) => location?.split('/').filter(Boolean).at(-1);
|
|
24
|
+
/** Media API resource path for an image or video ID, with the ID encoded as one path segment. */
|
|
25
|
+
const resourcePath = (resource, id, suffix = '') => `/${resource}/${encodeURIComponent(id)}${suffix}`;
|
|
26
|
+
const apiFailure = (method, path) => (cause) => new EbayApiError({ method, path, cause });
|
|
27
|
+
const videoStatusFinal = (video) => video.status !== undefined && TERMINAL_VIDEO_STATUSES.has(video.status);
|
|
28
|
+
/**
|
|
29
|
+
* Validates one of {@link MediaApi.waitForVideo}'s timing inputs.
|
|
30
|
+
*
|
|
31
|
+
* A zero, negative, or non-finite poll interval would leave the wait budget
|
|
32
|
+
* standing still while status requests kept going, so the boundary is checked
|
|
33
|
+
* before the first poll rather than inside the loop.
|
|
34
|
+
*
|
|
35
|
+
* @param value - Caller-supplied duration in milliseconds, or undefined.
|
|
36
|
+
* @param fallback - Duration used when the caller supplied none.
|
|
37
|
+
* @param name - Parameter name used in the validation message.
|
|
38
|
+
* @param minimum - Smallest accepted value.
|
|
39
|
+
* @returns An Effect with the duration to use, or a tagged input error.
|
|
40
|
+
*/
|
|
41
|
+
const requireDurationMs = (value, fallback, name, minimum) => {
|
|
42
|
+
if (value === undefined) {
|
|
43
|
+
return Effect.succeed(fallback);
|
|
44
|
+
}
|
|
45
|
+
if (!Number.isFinite(value) || value < minimum) {
|
|
46
|
+
return Effect.fail(new EndpointInputError({
|
|
47
|
+
parameter: name,
|
|
48
|
+
message: `${name} must be a finite number of milliseconds of at least ${minimum} when provided`,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
return Effect.succeed(value);
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Media API client. Image and video uploads go to eBay's `apim` host and return
|
|
55
|
+
* the artefacts (EPS URLs, video IDs) that Inventory API listings reference.
|
|
56
|
+
*/
|
|
57
|
+
export class MediaApi {
|
|
58
|
+
client;
|
|
59
|
+
/**
|
|
60
|
+
* Uploads local media and rewrites an inventory item's `product.imageUrls` /
|
|
61
|
+
* `product.videoIds` — the one composite in this surface; see {@link createMediaAttachMethods}.
|
|
62
|
+
*/
|
|
63
|
+
attachMediaToInventoryItem;
|
|
64
|
+
constructor(client, items) {
|
|
65
|
+
this.client = client;
|
|
66
|
+
this.attachMediaToInventoryItem = createMediaAttachMethods(items, this).attachMediaToInventoryItem;
|
|
67
|
+
}
|
|
68
|
+
url(suffix) {
|
|
69
|
+
const config = this.client.getConfig();
|
|
70
|
+
return `${getMediaBaseUrl(config.environment, config.apiBaseUrl)}${MEDIA_BASE_PATH}${suffix}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Uploads one picture to eBay Picture Services.
|
|
74
|
+
*
|
|
75
|
+
* @param input - File name, MIME type, and contents.
|
|
76
|
+
* @returns An Effect that succeeds with the EPS URL (and image ID when eBay returns one).
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* const image = await Effect.runPromise(
|
|
81
|
+
* mediaApi.createImageFromFile({ source: 'front.jpg', fileName: 'front.jpg', mimeType: 'image/jpeg', bytes }),
|
|
82
|
+
* );
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* @see https://developer.ebay.com/api-docs/commerce/media/resources/image/methods/createImageFromFile
|
|
86
|
+
*/
|
|
87
|
+
createImageFromFile = (input) => {
|
|
88
|
+
const client = this.client;
|
|
89
|
+
const path = `${MEDIA_BASE_PATH}/image/create_image_from_file`;
|
|
90
|
+
const url = this.url('/image/create_image_from_file');
|
|
91
|
+
return Effect.gen(function* () {
|
|
92
|
+
const upload = yield* requireObjectEffect(input, 'input');
|
|
93
|
+
const fileName = yield* requireStringEffect(upload.fileName, 'fileName');
|
|
94
|
+
const form = new FormData();
|
|
95
|
+
// Copy into a fresh ArrayBuffer-backed view: Blob rejects Node's ArrayBufferLike-typed views.
|
|
96
|
+
form.append('image', new Blob([new Uint8Array(upload.bytes)], { type: upload.mimeType }), fileName);
|
|
97
|
+
const response = yield* Effect.tryPromise({
|
|
98
|
+
try: () => client.postForResponse(url, form, {
|
|
99
|
+
absolute: true,
|
|
100
|
+
timeoutMs: UPLOAD_TIMEOUT_MS,
|
|
101
|
+
}),
|
|
102
|
+
catch: apiFailure('POST', path),
|
|
103
|
+
});
|
|
104
|
+
const imageUrl = response.data?.imageUrl;
|
|
105
|
+
if (!imageUrl) {
|
|
106
|
+
return yield* Effect.fail(new EbayApiError({
|
|
107
|
+
method: 'POST',
|
|
108
|
+
path,
|
|
109
|
+
cause: new Error('eBay returned no imageUrl for the uploaded picture'),
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
source: upload.source,
|
|
114
|
+
imageId: idFromLocation(response.headers.location),
|
|
115
|
+
imageUrl,
|
|
116
|
+
expirationDate: response.data?.expirationDate,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Uploads pictures one after another, keeping input order.
|
|
122
|
+
*
|
|
123
|
+
* @param files - Files to upload in listing order.
|
|
124
|
+
* @returns An Effect with every uploaded image, or a `MediaUploadError` naming the first failure and what preceded it.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* const { images } = await Effect.runPromise(mediaApi.uploadImageFiles(files));
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @see https://developer.ebay.com/api-docs/commerce/media/resources/image/methods/createImageFromFile
|
|
132
|
+
*/
|
|
133
|
+
uploadImageFiles = (files) => {
|
|
134
|
+
const createImageFromFile = this.createImageFromFile;
|
|
135
|
+
return Effect.gen(function* () {
|
|
136
|
+
const uploaded = [];
|
|
137
|
+
for (const [index, file] of files.entries()) {
|
|
138
|
+
const image = yield* createImageFromFile(file).pipe(Effect.mapError((cause) => new MediaUploadError({
|
|
139
|
+
source: file.source,
|
|
140
|
+
index,
|
|
141
|
+
uploaded: [...uploaded],
|
|
142
|
+
cause,
|
|
143
|
+
message: `upload of ${file.source} (file ${index + 1} of ${files.length}) failed: ${getErrorMessage(cause)}${uploaded.length > 0 ? `; already uploaded: ${uploaded.map((item) => item.imageUrl).join(', ')}` : ''}`,
|
|
144
|
+
})));
|
|
145
|
+
uploaded.push(image);
|
|
146
|
+
}
|
|
147
|
+
return { images: uploaded };
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Retrieves the EPS URL and expiry of an uploaded image.
|
|
152
|
+
*
|
|
153
|
+
* @param input - Media API image identifier.
|
|
154
|
+
* @returns An Effect that succeeds with eBay's ImageResponse.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```ts
|
|
158
|
+
* const image = await Effect.runPromise(mediaApi.getImage({ imageId: 'IMG-1' }));
|
|
159
|
+
* ```
|
|
160
|
+
*
|
|
161
|
+
* @see https://developer.ebay.com/api-docs/commerce/media/resources/image/methods/getImage
|
|
162
|
+
*/
|
|
163
|
+
getImage = (input) => {
|
|
164
|
+
const client = this.client;
|
|
165
|
+
const url = (imageId) => this.url(resourcePath('image', imageId));
|
|
166
|
+
return Effect.gen(function* () {
|
|
167
|
+
const validated = yield* requireObjectEffect(input, 'input');
|
|
168
|
+
const imageId = yield* requireStringEffect(validated.imageId, 'imageId');
|
|
169
|
+
return yield* Effect.tryPromise({
|
|
170
|
+
try: () => client.get(url(imageId), undefined, { absolute: true }),
|
|
171
|
+
catch: apiFailure('GET', `${MEDIA_BASE_PATH}${resourcePath('image', imageId)}`),
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Creates a video resource that a file can then be uploaded to.
|
|
177
|
+
*
|
|
178
|
+
* @param input - Title, exact byte size, and optional description.
|
|
179
|
+
* @returns An Effect that succeeds with the new video ID.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* const { videoId } = await Effect.runPromise(mediaApi.createVideo({ title: 'Demo', size: 1024 }));
|
|
184
|
+
* ```
|
|
185
|
+
*
|
|
186
|
+
* @see https://developer.ebay.com/api-docs/commerce/media/resources/video/methods/createVideo
|
|
187
|
+
*/
|
|
188
|
+
createVideo = (input) => {
|
|
189
|
+
const client = this.client;
|
|
190
|
+
const path = `${MEDIA_BASE_PATH}/video`;
|
|
191
|
+
const url = this.url('/video');
|
|
192
|
+
return Effect.gen(function* () {
|
|
193
|
+
const validated = yield* requireObjectEffect(input, 'input');
|
|
194
|
+
const title = yield* requireStringEffect(validated.title, 'title');
|
|
195
|
+
const body = {
|
|
196
|
+
title,
|
|
197
|
+
size: validated.size,
|
|
198
|
+
description: validated.description,
|
|
199
|
+
classification: ['ITEM'],
|
|
200
|
+
};
|
|
201
|
+
const response = yield* Effect.tryPromise({
|
|
202
|
+
try: () => client.postForResponse(url, body, { absolute: true }),
|
|
203
|
+
catch: apiFailure('POST', path),
|
|
204
|
+
});
|
|
205
|
+
const videoId = idFromLocation(response.headers.location);
|
|
206
|
+
if (!videoId) {
|
|
207
|
+
return yield* Effect.fail(new EbayApiError({
|
|
208
|
+
method: 'POST',
|
|
209
|
+
path,
|
|
210
|
+
cause: new Error('eBay returned no Location header with the new video ID'),
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
return { videoId };
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Uploads the file for a created video resource.
|
|
218
|
+
*
|
|
219
|
+
* @param input - Video ID and file contents (exactly the size given to createVideo).
|
|
220
|
+
* @returns An Effect that succeeds once eBay accepted the bytes.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* await Effect.runPromise(mediaApi.uploadVideo({ videoId: 'VID-1', bytes }));
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* @see https://developer.ebay.com/api-docs/commerce/media/resources/video/methods/uploadVideo
|
|
228
|
+
*/
|
|
229
|
+
uploadVideo = (input) => {
|
|
230
|
+
const client = this.client;
|
|
231
|
+
const url = (videoId) => this.url(resourcePath('video', videoId, '/upload'));
|
|
232
|
+
return Effect.gen(function* () {
|
|
233
|
+
const validated = yield* requireObjectEffect(input, 'input');
|
|
234
|
+
const videoId = yield* requireStringEffect(validated.videoId, 'videoId');
|
|
235
|
+
yield* Effect.tryPromise({
|
|
236
|
+
try: () => client.post(url(videoId), validated.bytes, {
|
|
237
|
+
absolute: true,
|
|
238
|
+
timeoutMs: UPLOAD_TIMEOUT_MS,
|
|
239
|
+
headers: {
|
|
240
|
+
'Content-Type': 'application/octet-stream',
|
|
241
|
+
'Content-Length': String(validated.bytes.byteLength),
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
catch: apiFailure('POST', `${MEDIA_BASE_PATH}${resourcePath('video', videoId, '/upload')}`),
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Retrieves a video's metadata, processing status, and playlists.
|
|
250
|
+
*
|
|
251
|
+
* @param input - Media API video identifier.
|
|
252
|
+
* @returns An Effect that succeeds with eBay's Video.
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* ```ts
|
|
256
|
+
* const video = await Effect.runPromise(mediaApi.getVideo({ videoId: 'VID-1' }));
|
|
257
|
+
* ```
|
|
258
|
+
*
|
|
259
|
+
* @see https://developer.ebay.com/api-docs/commerce/media/resources/video/methods/getVideo
|
|
260
|
+
*/
|
|
261
|
+
getVideo = (input) => {
|
|
262
|
+
const client = this.client;
|
|
263
|
+
const url = (videoId) => this.url(resourcePath('video', videoId));
|
|
264
|
+
return Effect.gen(function* () {
|
|
265
|
+
const validated = yield* requireObjectEffect(input, 'input');
|
|
266
|
+
const videoId = yield* requireStringEffect(validated.videoId, 'videoId');
|
|
267
|
+
return yield* Effect.tryPromise({
|
|
268
|
+
try: () => client.get(url(videoId), undefined, { absolute: true }),
|
|
269
|
+
catch: apiFailure('GET', `${MEDIA_BASE_PATH}${resourcePath('video', videoId)}`),
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Polls getVideo until the status is final or the wait budget runs out. The
|
|
275
|
+
* last sleep is cut to the remaining budget, so the total wait never exceeds
|
|
276
|
+
* `maxWaitMs` by more than one status request. Both timings are validated
|
|
277
|
+
* before the first request, so no poll interval can stall the budget.
|
|
278
|
+
*
|
|
279
|
+
* @param input - Video ID plus optional wait budget and poll interval.
|
|
280
|
+
* @returns An Effect with the latest Video; check `status` — it may still be PROCESSING.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```ts
|
|
284
|
+
* const video = await Effect.runPromise(mediaApi.waitForVideo({ videoId: 'VID-1', maxWaitMs: 60_000 }));
|
|
285
|
+
* ```
|
|
286
|
+
*
|
|
287
|
+
* @see https://developer.ebay.com/api-docs/commerce/media/resources/video/methods/getVideo
|
|
288
|
+
*/
|
|
289
|
+
waitForVideo = (input) => {
|
|
290
|
+
const getVideo = this.getVideo;
|
|
291
|
+
return Effect.gen(function* () {
|
|
292
|
+
const maxWaitMs = yield* requireDurationMs(input.maxWaitMs, DEFAULT_VIDEO_WAIT_MS, 'maxWaitMs', 0);
|
|
293
|
+
const pollIntervalMs = yield* requireDurationMs(input.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS, 'pollIntervalMs', 1);
|
|
294
|
+
let waitedMs = 0;
|
|
295
|
+
let video = yield* getVideo({ videoId: input.videoId });
|
|
296
|
+
while (!videoStatusFinal(video) && waitedMs < maxWaitMs) {
|
|
297
|
+
const sleepMs = Math.min(pollIntervalMs, maxWaitMs - waitedMs);
|
|
298
|
+
yield* Effect.sleep(sleepMs);
|
|
299
|
+
waitedMs += sleepMs;
|
|
300
|
+
video = yield* getVideo({ videoId: input.videoId });
|
|
301
|
+
}
|
|
302
|
+
return video;
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* Runs the whole video lifecycle: create the resource, upload the bytes, wait for processing.
|
|
307
|
+
*
|
|
308
|
+
* @param input - File plus optional title, description, and wait settings.
|
|
309
|
+
* @returns An Effect with the Video (its `videoId` is always set); `status` is LIVE when ready.
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```ts
|
|
313
|
+
* const video = await Effect.runPromise(mediaApi.uploadVideoFile({ file }));
|
|
314
|
+
* ```
|
|
315
|
+
*
|
|
316
|
+
* @see https://developer.ebay.com/api-docs/commerce/media/resources/video/methods/uploadVideo
|
|
317
|
+
*/
|
|
318
|
+
uploadVideoFile = (input) => {
|
|
319
|
+
const { createVideo, uploadVideo, waitForVideo } = this;
|
|
320
|
+
return Effect.gen(function* () {
|
|
321
|
+
const { videoId } = yield* createVideo({
|
|
322
|
+
title: input.title ?? input.file.fileName,
|
|
323
|
+
size: input.file.bytes.byteLength,
|
|
324
|
+
description: input.description,
|
|
325
|
+
});
|
|
326
|
+
yield* uploadVideo({ videoId, bytes: input.file.bytes });
|
|
327
|
+
const video = yield* waitForVideo({
|
|
328
|
+
videoId,
|
|
329
|
+
maxWaitMs: input.maxWaitMs,
|
|
330
|
+
pollIntervalMs: input.pollIntervalMs,
|
|
331
|
+
});
|
|
332
|
+
return { ...video, videoId: video.videoId ?? videoId };
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { EndpointInputError } from '../../api/shared/request.js';
|
|
2
|
+
import { getErrorMessage } from '../../utils/errors.js';
|
|
3
|
+
import { Data, Effect } from 'effect';
|
|
4
|
+
/** Attach run that uploaded media but left the inventory item untouched. */
|
|
5
|
+
export class MediaAttachError extends Data.TaggedError('MediaAttachError') {
|
|
6
|
+
}
|
|
7
|
+
const dedupe = (values) => [...new Set(values)];
|
|
8
|
+
/** Keeps only the fields createOrReplaceInventoryItem accepts (drops sku/group metadata). */
|
|
9
|
+
const toInventoryItemBody = (item) => ({
|
|
10
|
+
availability: item.availability,
|
|
11
|
+
condition: item.condition,
|
|
12
|
+
conditionDescription: item.conditionDescription,
|
|
13
|
+
conditionDescriptors: item.conditionDescriptors,
|
|
14
|
+
packageWeightAndSize: item.packageWeightAndSize,
|
|
15
|
+
product: item.product,
|
|
16
|
+
});
|
|
17
|
+
/** `Content-Language` value for an inventory item locale such as `en_US`. */
|
|
18
|
+
const contentLanguageOf = (locale) => locale?.replace('_', '-');
|
|
19
|
+
/**
|
|
20
|
+
* Merges freshly uploaded media into the product's current media. With
|
|
21
|
+
* `replaceExisting`, a family is replaced only when the caller supplied files
|
|
22
|
+
* for it; the other family is carried over untouched.
|
|
23
|
+
*/
|
|
24
|
+
const mergeMedia = (product, input, uploaded) => {
|
|
25
|
+
const replaceImages = input.replaceExisting === true && input.images.length > 0;
|
|
26
|
+
const replaceVideos = input.replaceExisting === true && input.videos.length > 0;
|
|
27
|
+
return {
|
|
28
|
+
imageUrls: dedupe(replaceImages ? uploaded.imageUrls : [...(product.imageUrls ?? []), ...uploaded.imageUrls]),
|
|
29
|
+
videoIds: dedupe(replaceVideos ? uploaded.videoIds : [...(product.videoIds ?? []), ...uploaded.videoIds]),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/** Media references from the outcomes that can go onto the item. */
|
|
33
|
+
const uploadedMedia = (images, videos) => ({
|
|
34
|
+
imageUrls: images.flatMap((outcome) => (outcome.imageUrl ? [outcome.imageUrl] : [])),
|
|
35
|
+
videoIds: videos.flatMap((outcome) => outcome.status !== 'failed' && outcome.videoId ? [outcome.videoId] : []),
|
|
36
|
+
});
|
|
37
|
+
const imageOutcome = (image) => ({
|
|
38
|
+
source: image.source,
|
|
39
|
+
kind: 'image',
|
|
40
|
+
status: 'uploaded',
|
|
41
|
+
imageUrl: image.imageUrl,
|
|
42
|
+
imageId: image.imageId,
|
|
43
|
+
expirationDate: image.expirationDate,
|
|
44
|
+
});
|
|
45
|
+
const videoOutcome = (source, video) => {
|
|
46
|
+
const base = {
|
|
47
|
+
source,
|
|
48
|
+
kind: 'video',
|
|
49
|
+
videoId: video.videoId,
|
|
50
|
+
videoStatus: video.status,
|
|
51
|
+
};
|
|
52
|
+
if (video.status === 'LIVE') {
|
|
53
|
+
return { ...base, status: 'uploaded' };
|
|
54
|
+
}
|
|
55
|
+
if (video.status === 'BLOCKED' || video.status === 'PROCESSING_FAILED') {
|
|
56
|
+
const reasons = video.moderation?.rejectReasons?.join(', ');
|
|
57
|
+
return {
|
|
58
|
+
...base,
|
|
59
|
+
status: 'failed',
|
|
60
|
+
error: video.statusMessage ?? reasons ?? `eBay reported ${video.status}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { ...base, status: 'processing' };
|
|
64
|
+
};
|
|
65
|
+
const failedOutcome = (file, kind, cause) => ({
|
|
66
|
+
source: file.source,
|
|
67
|
+
kind,
|
|
68
|
+
status: 'failed',
|
|
69
|
+
error: getErrorMessage(cause),
|
|
70
|
+
});
|
|
71
|
+
const attachFailure = (summary, result) => new MediaAttachError({
|
|
72
|
+
message: `${summary}. Results: ${JSON.stringify({ images: result.images, videos: result.videos })}`,
|
|
73
|
+
result,
|
|
74
|
+
});
|
|
75
|
+
/** Uploads every file, turning each failure into a `failed` outcome instead of aborting. */
|
|
76
|
+
const uploadAll = (media, input) => Effect.gen(function* () {
|
|
77
|
+
const images = yield* Effect.forEach(input.images, (file) => media.createImageFromFile(file).pipe(Effect.map(imageOutcome), Effect.catchAll((cause) => Effect.succeed(failedOutcome(file, 'image', cause)))));
|
|
78
|
+
const videos = yield* Effect.forEach(input.videos, (file) => media
|
|
79
|
+
.uploadVideoFile({
|
|
80
|
+
file,
|
|
81
|
+
maxWaitMs: input.maxWaitMs,
|
|
82
|
+
pollIntervalMs: input.pollIntervalMs,
|
|
83
|
+
})
|
|
84
|
+
.pipe(Effect.map((video) => videoOutcome(file.source, video)), Effect.catchAll((cause) => Effect.succeed(failedOutcome(file, 'video', cause)))));
|
|
85
|
+
return { images, videos };
|
|
86
|
+
});
|
|
87
|
+
/**
|
|
88
|
+
* Builds the attach orchestration over the inventory-item and media primitives.
|
|
89
|
+
*
|
|
90
|
+
* This is deliberately the one composite in the media surface: it checks that the
|
|
91
|
+
* inventory item exists, uploads every file, then reads the item again right
|
|
92
|
+
* before the write and rewrites only `product.imageUrls` / `product.videoIds`
|
|
93
|
+
* on that fresh copy, so edits made during the uploads survive. It never
|
|
94
|
+
* publishes an offer, and it leaves the item untouched when any upload fails
|
|
95
|
+
* unless `allowPartial` is set.
|
|
96
|
+
*
|
|
97
|
+
* @param items - Inventory item methods used to read and rewrite the item.
|
|
98
|
+
* @param media - Media API used for the uploads.
|
|
99
|
+
* @returns The attach method map to merge onto {@link MediaApi}.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* const { attachMediaToInventoryItem } = createMediaAttachMethods(items, media);
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export const createMediaAttachMethods = (items, media) => ({
|
|
107
|
+
attachMediaToInventoryItem: (input) => Effect.gen(function* () {
|
|
108
|
+
const total = input.images.length + input.videos.length;
|
|
109
|
+
if (total === 0) {
|
|
110
|
+
return yield* Effect.fail(new EndpointInputError({
|
|
111
|
+
parameter: 'imagePaths',
|
|
112
|
+
message: 'provide at least one image or video to attach',
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
// Existence check before any upload; the write below re-reads the item.
|
|
116
|
+
const snapshot = yield* items.getInventoryItem({ sku: input.sku });
|
|
117
|
+
const { images, videos } = yield* uploadAll(media, input);
|
|
118
|
+
const failed = [...images, ...videos].filter((outcome) => outcome.status === 'failed');
|
|
119
|
+
const uploaded = uploadedMedia(images, videos);
|
|
120
|
+
const result = {
|
|
121
|
+
sku: input.sku,
|
|
122
|
+
updated: false,
|
|
123
|
+
images,
|
|
124
|
+
videos,
|
|
125
|
+
...mergeMedia(snapshot.product ?? {}, input, uploaded),
|
|
126
|
+
};
|
|
127
|
+
if (failed.length > 0 && !input.allowPartial) {
|
|
128
|
+
return yield* Effect.fail(attachFailure(`${failed.length} of ${total} uploads failed; inventory item ${input.sku} was not updated (pass allowPartial: true to attach the successful ones)`, result));
|
|
129
|
+
}
|
|
130
|
+
if (uploaded.imageUrls.length + uploaded.videoIds.length === 0) {
|
|
131
|
+
return yield* Effect.fail(attachFailure(`no media was uploaded successfully; inventory item ${input.sku} was not updated`, result));
|
|
132
|
+
}
|
|
133
|
+
// Re-read right before the complete-replacement PUT: the uploads can take
|
|
134
|
+
// minutes, and the earlier snapshot must not overwrite edits made meanwhile.
|
|
135
|
+
const latest = yield* items.getInventoryItem({ sku: input.sku });
|
|
136
|
+
const product = latest.product ?? {};
|
|
137
|
+
const merged = mergeMedia(product, input, uploaded);
|
|
138
|
+
yield* items.createOrReplaceInventoryItem({
|
|
139
|
+
sku: input.sku,
|
|
140
|
+
contentLanguage: contentLanguageOf(latest.locale),
|
|
141
|
+
body: { ...toInventoryItemBody(latest), product: { ...product, ...merged } },
|
|
142
|
+
});
|
|
143
|
+
return { ...result, ...merged, updated: true };
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { BUY_IT_NOW_MARGIN_LABEL, meetsBuyItNowMargin } from '../../api/shared/auctionPricing.js';
|
|
2
|
+
import { EndpointInputError } from '../../api/shared/request.js';
|
|
3
|
+
import { FormatType, ListingDuration } from '../../types/ebayEnums.js';
|
|
4
|
+
import { Effect } from 'effect';
|
|
5
|
+
/** The only `availableQuantity` eBay accepts on an auction offer. */
|
|
6
|
+
const AUCTION_QUANTITY = 1;
|
|
7
|
+
const inputError = (parameter, message) => new EndpointInputError({ parameter, message: `${parameter}: ${message}` });
|
|
8
|
+
const parseAmount = (amount) => {
|
|
9
|
+
if (amount?.value === undefined) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const parsed = Number(amount.value);
|
|
13
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
14
|
+
};
|
|
15
|
+
const auctionViolation = (offer, parameter) => {
|
|
16
|
+
if (offer.listingDuration === ListingDuration.GTC) {
|
|
17
|
+
return inputError(`${parameter}.listingDuration`, 'AUCTION offers need a day-count listingDuration such as DAYS_7; GTC is only valid for FIXED_PRICE offers');
|
|
18
|
+
}
|
|
19
|
+
if (offer.availableQuantity !== undefined && offer.availableQuantity !== AUCTION_QUANTITY) {
|
|
20
|
+
return inputError(`${parameter}.availableQuantity`, 'AUCTION offers list a single unit; omit availableQuantity or set it to 1');
|
|
21
|
+
}
|
|
22
|
+
if (offer.listingPolicies?.eBayPlusIfEligible === true) {
|
|
23
|
+
return inputError(`${parameter}.listingPolicies.eBayPlusIfEligible`, 'eBayPlusIfEligible is not applicable for AUCTION offers');
|
|
24
|
+
}
|
|
25
|
+
if (offer.quantityLimitPerBuyer !== undefined) {
|
|
26
|
+
return inputError(`${parameter}.quantityLimitPerBuyer`, 'quantityLimitPerBuyer does not apply to AUCTION offers');
|
|
27
|
+
}
|
|
28
|
+
if (offer.listingPolicies?.bestOfferTerms?.bestOfferEnabled === true &&
|
|
29
|
+
offer.pricingSummary?.price !== undefined) {
|
|
30
|
+
return inputError(`${parameter}.listingPolicies.bestOfferTerms.bestOfferEnabled`, 'an AUCTION offer can carry Best Offer or a Buy It Now price (pricingSummary.price), not both');
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const fixedPriceViolation = (offer, parameter) => {
|
|
34
|
+
if (offer.listingDuration !== undefined && offer.listingDuration !== ListingDuration.GTC) {
|
|
35
|
+
return inputError(`${parameter}.listingDuration`, 'FIXED_PRICE offers must use listingDuration GTC; day-count durations are for AUCTION offers');
|
|
36
|
+
}
|
|
37
|
+
const pricing = offer.pricingSummary;
|
|
38
|
+
if (pricing?.auctionStartPrice !== undefined || pricing?.auctionReservePrice !== undefined) {
|
|
39
|
+
return inputError(`${parameter}.pricingSummary`, 'auctionStartPrice and auctionReservePrice only apply to AUCTION offers; FIXED_PRICE offers use pricingSummary.price');
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** Fields eBay requires when an AUCTION offer is created (its createOffer error catalogue). */
|
|
43
|
+
const auctionCreateViolation = (offer, parameter) => {
|
|
44
|
+
if (offer.listingDuration === undefined) {
|
|
45
|
+
return inputError(`${parameter}.listingDuration`, 'listingDuration is required for AUCTION offers (a day count such as DAYS_7)');
|
|
46
|
+
}
|
|
47
|
+
if (offer.pricingSummary?.auctionStartPrice === undefined) {
|
|
48
|
+
return inputError(`${parameter}.pricingSummary.auctionStartPrice`, 'auctionStartPrice is required for AUCTION offers');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** Price relations that hold for any body carrying an opening bid, with or without a format. */
|
|
52
|
+
const auctionPriceViolation = (offer, parameter) => {
|
|
53
|
+
const start = parseAmount(offer.pricingSummary?.auctionStartPrice);
|
|
54
|
+
if (start === undefined) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const reserve = parseAmount(offer.pricingSummary?.auctionReservePrice);
|
|
58
|
+
if (reserve !== undefined && reserve <= start) {
|
|
59
|
+
return inputError(`${parameter}.pricingSummary.auctionReservePrice`, 'auctionReservePrice must be higher than auctionStartPrice');
|
|
60
|
+
}
|
|
61
|
+
const buyItNow = parseAmount(offer.pricingSummary?.price);
|
|
62
|
+
if (buyItNow !== undefined && !meetsBuyItNowMargin(start, buyItNow)) {
|
|
63
|
+
return inputError(`${parameter}.pricingSummary.price`, `the Buy It Now price (pricingSummary.price) must be at least ${BUY_IT_NOW_MARGIN_LABEL} higher than auctionStartPrice`);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Finds the first eBay listing-format rule an offer body breaks, if any.
|
|
68
|
+
*
|
|
69
|
+
* AUCTION offers need a day-count listingDuration and an auctionStartPrice, list a
|
|
70
|
+
* single unit (availableQuantity omitted or 1), cannot carry per-buyer limits or
|
|
71
|
+
* eBay Plus, and cannot combine Best Offer with a Buy It Now price; FIXED_PRICE
|
|
72
|
+
* offers must use GTC and cannot carry auction prices. A reserve must exceed the
|
|
73
|
+
* opening bid and a Buy It Now price must be at least 30% above it. Update bodies
|
|
74
|
+
* carry no format, so only the format-free rules apply to them.
|
|
75
|
+
*
|
|
76
|
+
* @param offer - Create or update offer body.
|
|
77
|
+
* @param parameter - Parameter path used to label the failing field (for example `body`).
|
|
78
|
+
* @returns The tagged input error describing the violation, or undefined when the body is consistent.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* const violation = findOfferFormatViolation({ format: 'AUCTION', listingDuration: 'GTC' }, 'body');
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export const findOfferFormatViolation = (offer, parameter) => {
|
|
86
|
+
let formatViolation;
|
|
87
|
+
if (offer.format === FormatType.AUCTION) {
|
|
88
|
+
formatViolation =
|
|
89
|
+
auctionViolation(offer, parameter) ?? auctionCreateViolation(offer, parameter);
|
|
90
|
+
}
|
|
91
|
+
else if (offer.format === FormatType.FIXED_PRICE) {
|
|
92
|
+
formatViolation = fixedPriceViolation(offer, parameter);
|
|
93
|
+
}
|
|
94
|
+
return formatViolation ?? auctionPriceViolation(offer, parameter);
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Rejects an offer body that mixes AUCTION and FIXED_PRICE fields before it reaches eBay.
|
|
98
|
+
*
|
|
99
|
+
* @param offer - Create or update offer body.
|
|
100
|
+
* @param parameter - Parameter path used to label the failing field (for example `body`).
|
|
101
|
+
* @returns An Effect that succeeds when the body is consistent and fails with `EndpointInputError` otherwise.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* yield* validateOfferFormatEffect(body, 'body');
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export const validateOfferFormatEffect = (offer, parameter) => {
|
|
109
|
+
const violation = findOfferFormatViolation(offer, parameter);
|
|
110
|
+
return violation ? Effect.fail(violation) : Effect.void;
|
|
111
|
+
};
|