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.
Files changed (52) hide show
  1. package/README.de.md +23 -15
  2. package/README.es.md +23 -15
  3. package/README.fr.md +23 -15
  4. package/README.ja.md +23 -15
  5. package/README.ko.md +23 -15
  6. package/README.md +108 -14
  7. package/README.pt-BR.md +23 -15
  8. package/README.ru.md +23 -15
  9. package/README.zh-CN.md +23 -15
  10. package/build/api/client.js +88 -12
  11. package/build/api/index.js +12 -0
  12. package/build/api/listing-management/items.js +4 -1
  13. package/build/api/listing-management/media.js +335 -0
  14. package/build/api/listing-management/mediaAttach.js +145 -0
  15. package/build/api/listing-management/offerFormat.js +111 -0
  16. package/build/api/listing-management/offers.js +82 -23
  17. package/build/api/other/browse.js +120 -0
  18. package/build/api/other/browseFilter.js +74 -0
  19. package/build/api/other/browseMappers.js +205 -0
  20. package/build/api/other/browseTypes.js +8 -0
  21. package/build/api/other/browseValidators.js +180 -0
  22. package/build/api/shared/auctionPricing.js +44 -0
  23. package/build/api/shared/request.js +2 -2
  24. package/build/api/trading/listingFormat.js +179 -0
  25. package/build/api/trading/trading.js +68 -18
  26. package/build/auth/credentialSession.js +2 -3
  27. package/build/auth/oauth.js +26 -8
  28. package/build/auth/scopeUtils.js +5 -0
  29. package/build/config/credentialFile.js +16 -0
  30. package/build/config/environment.js +72 -4
  31. package/build/config/mediaAccess.js +51 -0
  32. package/build/mcp/runtime.js +13 -5
  33. package/build/schemas/inventory-management/inventory.js +102 -26
  34. package/build/schemas/inventory-management/media.js +106 -0
  35. package/build/schemas/other/browse.js +65 -0
  36. package/build/skills/content.js +13 -6
  37. package/build/tools/categories/account.js +1 -1
  38. package/build/tools/categories/browse.js +15 -0
  39. package/build/tools/categories/index.js +2 -1
  40. package/build/tools/categories/inventory.js +31 -14
  41. package/build/tools/categories/media.js +76 -0
  42. package/build/tools/categories/taxonomy.js +2 -2
  43. package/build/tools/categories/trading.js +8 -7
  44. package/build/tools/schemas.js +5 -2
  45. package/build/tools/ui/maps.js +21 -2
  46. package/build/types/ebayEnums.js +29 -0
  47. package/build/utils/errors.js +84 -0
  48. package/build/utils/http.js +61 -46
  49. package/build/utils/localMedia.js +180 -0
  50. package/build/utils/objects.js +26 -0
  51. package/build/utils/trading/trading.js +14 -2
  52. 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
+ };