diff --git a/src/integrations/__tests__/marketplaceworker.test.js b/src/integrations/__tests__/marketplaceworker.test.js index f0ba12a..9bf7cb1 100644 --- a/src/integrations/__tests__/marketplaceworker.test.js +++ b/src/integrations/__tests__/marketplaceworker.test.js @@ -7,7 +7,7 @@ const listingFindById = jest.fn(); const listingVarientFind = jest.fn(); jest.unstable_mockModule('../../config.js', () => ({ - default: { server: { logLevel: 'error' } }, + default: { server: { logLevel: 'error' }, app: { urlApi: 'https://api.example.com' } }, })); jest.unstable_mockModule('log4js', () => ({ default: { @@ -54,10 +54,13 @@ jest.unstable_mockModule('../marketplaces/ebay/accountPolicies.js', () => ({ persistMarketplaceMapping: jest.fn(), })); jest.unstable_mockModule('../marketplaces/tiktokShop.js', () => ({})); +const ensureWebhookSubscriptions = jest.fn(); + jest.unstable_mockModule('../marketplaces/ebay/index.js', () => ({ updateItem, createItem: jest.fn(), ensureAuthenticatedMarketplace: jest.fn(async (marketplace) => marketplace), + ensureWebhookSubscriptions, publishOfferForSku: jest.fn(), syncListingImages: jest.fn(), })); @@ -70,7 +73,7 @@ jest.unstable_mockModule('../marketplaceSync.js', () => ({ pushShipmentFulfillment: jest.fn(), })); -const { updateListing } = await import('../marketplaceworker.js'); +const { updateListing, syncWebhooks } = await import('../marketplaceworker.js'); function leanDoc(value) { return { @@ -133,3 +136,62 @@ describe('updateListing fingerprint skip', () => { ); }); }); + +describe('syncWebhooks', () => { + const user = { _id: 'user-1' }; + + beforeEach(() => { + jest.clearAllMocks(); + editObject.mockImplementation(async ({ updateData }) => ({ + _id: 'mp-1', + name: 'eBay UK', + provider: 'ebay', + ...updateData, + })); + }); + + it('persists webhookUrl and verificationToken then subscribes', async () => { + ensureWebhookSubscriptions.mockResolvedValue({ destinationId: 'dest-1', created: ['ORDER'] }); + + const result = await syncWebhooks( + { _id: 'mp-1', name: 'eBay UK', provider: 'ebay', config: {} }, + user + ); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + config: expect.objectContaining({ + webhookUrl: 'https://api.example.com/marketplaces/mp-1/hook', + verificationToken: expect.stringMatching(/^[a-f0-9]{64}$/), + }), + }), + }) + ); + expect(ensureWebhookSubscriptions).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + destinationId: 'dest-1', + created: ['ORDER'], + webhookUrl: 'https://api.example.com/marketplaces/mp-1/hook', + }); + }); + + it('does not persist when webhook config is already present', async () => { + ensureWebhookSubscriptions.mockResolvedValue({ destinationId: 'dest-1', created: [] }); + const marketplace = { + _id: 'mp-1', + name: 'eBay UK', + provider: 'ebay', + config: { + webhookUrl: 'https://custom.example/hook', + verificationToken: 'existing-token-32-characters-long!', + }, + }; + + const result = await syncWebhooks(marketplace, user); + + expect(editObject).not.toHaveBeenCalled(); + expect(ensureWebhookSubscriptions).toHaveBeenCalledWith(marketplace); + expect(result.webhookUrl).toBe('https://custom.example/hook'); + }); +}); diff --git a/src/integrations/marketplace.js b/src/integrations/marketplace.js index 99596e3..419a96a 100644 --- a/src/integrations/marketplace.js +++ b/src/integrations/marketplace.js @@ -381,6 +381,17 @@ export function ensureWebhookSubscriptions(marketplace, user, { wait = false } = ); } +export function syncWebhooks(marketplace, user, { wait = true } = {}) { + return sendJob( + 'syncWebhooks', + { + ...marketplacePayload(marketplace), + ...userPayload(user), + }, + { wait } + ); +} + export function pushMarketplaceShipmentFulfillment(marketplace, user, shipment) { return sendJob('pushShipmentFulfillment', { ...marketplacePayload(marketplace), diff --git a/src/integrations/marketplaces/__tests__/webhookConfig.test.js b/src/integrations/marketplaces/__tests__/webhookConfig.test.js new file mode 100644 index 0000000..b4b160f --- /dev/null +++ b/src/integrations/marketplaces/__tests__/webhookConfig.test.js @@ -0,0 +1,41 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../config.js', () => ({ + default: { app: { urlApi: 'https://api.example.com/' }, server: { logLevel: 'error' } }, +})); + +const { buildMarketplaceWebhookUrl, webhookConfigUpdates, generateWebhookVerificationToken } = + await import('../webhookConfig.js'); + +describe('webhookConfig', () => { + it('builds the public hook URL from urlApi and marketplace id', () => { + expect(buildMarketplaceWebhookUrl({ _id: 'mkt-1' })).toBe( + 'https://api.example.com/marketplaces/mkt-1/hook' + ); + }); + + it('returns an empty URL when the marketplace id is missing', () => { + expect(buildMarketplaceWebhookUrl({})).toBe(''); + }); + + it('generates a 64-character verification token', () => { + expect(generateWebhookVerificationToken()).toMatch(/^[a-f0-9]{64}$/); + }); + + it('fills missing webhookUrl and verificationToken', () => { + const updates = webhookConfigUpdates({ _id: 'mkt-1', config: {} }); + expect(updates.webhookUrl).toBe('https://api.example.com/marketplaces/mkt-1/hook'); + expect(updates.verificationToken).toMatch(/^[a-f0-9]{64}$/); + }); + + it('does not overwrite existing webhook fields', () => { + const updates = webhookConfigUpdates({ + _id: 'mkt-1', + config: { + webhookUrl: 'https://custom.example/hook', + verificationToken: 'existing-token-32-characters-long!', + }, + }); + expect(updates).toEqual({}); + }); +}); diff --git a/src/integrations/marketplaces/ebay/__tests__/notifications.test.js b/src/integrations/marketplaces/ebay/__tests__/notifications.test.js index 74a1d8d..a1a45d9 100644 --- a/src/integrations/marketplaces/ebay/__tests__/notifications.test.js +++ b/src/integrations/marketplaces/ebay/__tests__/notifications.test.js @@ -1,6 +1,13 @@ -import { describe, expect, it, jest } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import crypto from 'crypto'; +jest.unstable_mockModule('../../../../config.js', () => ({ + default: { + app: { urlApi: 'https://api.example.com' }, + server: { logLevel: 'error' }, + smtp: { from: 'FarmControl ' }, + }, +})); jest.unstable_mockModule('../shared.js', () => ({ makeRequest: jest.fn(), getApiBaseUrl: () => 'https://api.sandbox.ebay.com', @@ -11,9 +18,12 @@ jest.unstable_mockModule('../shared.js', () => ({ })); const { makeRequest } = await import('../shared.js'); -const { buildWebhookChallengeResponse, verifyNotificationSignature } = await import( - '../notifications.js' -); +const { + buildWebhookChallengeResponse, + verifyNotificationSignature, + ensureWebhookSubscriptions, + resolveNotificationAlertEmail, +} = await import('../notifications.js'); describe('eBay notification challenge', () => { it('hashes challengeCode + verificationToken + endpoint', () => { @@ -68,3 +78,155 @@ describe('eBay notification signature', () => { ).toBe(false); }); }); + +describe('eBay webhook subscriptions', () => { + const marketplace = { + name: 'eBay', + config: { + verificationToken: 'verify-token-32-characters-long!!', + webhookUrl: 'https://example.com/marketplaces/1/hook', + accessToken: 'user-token', + }, + }; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + makeRequest.mockReset(); + globalThis.fetch = jest.fn(async (url, options) => { + if (String(url).includes('/identity/v1/oauth2/token')) { + const body = String(options?.body || ''); + expect(body).toContain('grant_type=client_credentials'); + expect(decodeURIComponent(body)).toContain('https://api.ebay.com/oauth/api_scope'); + return { + ok: true, + status: 200, + json: async () => ({ access_token: 'app-token', expires_in: 7200 }), + }; + } + throw new Error(`unexpected fetch ${url}`); + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('throws when webhook URL and verification token are missing', async () => { + await expect(ensureWebhookSubscriptions({ name: 'eBay', config: {} })).rejects.toThrow( + /webhook URL and verification token/ + ); + }); + + it('resolves the alert email from SMTP from', () => { + expect(resolveNotificationAlertEmail({})).toBe('noreply@farmcontrol.app'); + expect( + resolveNotificationAlertEmail({ config: { notificationAlertEmail: 'ops@example.com' } }) + ).toBe('ops@example.com'); + }); + + it('creates notification config before creating a destination', async () => { + makeRequest.mockImplementation(async ({ method = 'GET', path }) => { + if (path.endsWith('/config') && method === 'GET') return null; + if (path.endsWith('/config') && method === 'PUT') return null; + if (path.endsWith('/destination') && method === 'GET') return { destinations: [] }; + if (path.endsWith('/destination') && method === 'POST') return { destinationId: 'dest-1' }; + if (path.endsWith('/topic')) { + return { + topics: [ + { + topicId: 'ORDER', + status: 'ENABLED', + supportedPayloads: [{ schemaVersion: '1.0' }], + }, + ], + }; + } + if (path.endsWith('/subscription') && method === 'GET') return { subscriptions: [] }; + if (path.endsWith('/subscription') && method === 'POST') return {}; + return null; + }); + + const result = await ensureWebhookSubscriptions(marketplace); + + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + path: '/commerce/notification/v1/config', + body: { alertEmail: 'noreply@farmcontrol.app' }, + marketplaceHeaders: false, + marketplace: expect.objectContaining({ + name: 'eBay', + config: expect.objectContaining({ accessToken: 'app-token' }), + }), + }) + ); + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/commerce/notification/v1/destination', + }) + ); + expect(result).toMatchObject({ destinationId: 'dest-1', created: ['ORDER'] }); + }); + + it('continues destination setup when eBay /config PUT returns 500', async () => { + makeRequest.mockImplementation(async ({ method = 'GET', path }) => { + if (path.endsWith('/config') && method === 'GET') return null; + if (path.endsWith('/config') && method === 'PUT') { + throw new Error('eBay API error (500): There was a problem with an eBay internal system'); + } + if (path.endsWith('/destination') && method === 'GET') return { destinations: [] }; + if (path.endsWith('/destination') && method === 'POST') return { destinationId: 'dest-1' }; + if (path.endsWith('/topic')) return { topics: [] }; + if (path.endsWith('/subscription')) return { subscriptions: [] }; + return null; + }); + + const result = await ensureWebhookSubscriptions(marketplace); + expect(result).toMatchObject({ destinationId: 'dest-1', created: [] }); + }); + + it('explains how to set /config when destination create is rejected', async () => { + makeRequest.mockImplementation(async ({ method = 'GET', path }) => { + if (path.endsWith('/config') && method === 'GET') return null; + if (path.endsWith('/config') && method === 'PUT') { + throw new Error('eBay API error (500): internal'); + } + if (path.endsWith('/destination') && method === 'GET') return { destinations: [] }; + if (path.endsWith('/destination') && method === 'POST') { + throw new Error('eBay API error (409): Please provide configurations required for notifications'); + } + return null; + }); + + await expect(ensureWebhookSubscriptions(marketplace)).rejects.toThrow(/Developer Portal/); + }); + + it('does not replace an existing alert email', async () => { + makeRequest.mockImplementation(async ({ method = 'GET', path }) => { + if (path.endsWith('/config')) return { alertEmail: 'existing@example.com' }; + if (path.endsWith('/destination') && method === 'GET') { + return { + destinations: [ + { + destinationId: 'dest-1', + deliveryConfig: { endpoint: marketplace.config.webhookUrl }, + }, + ], + }; + } + if (path.endsWith('/topic')) return { topics: [] }; + if (path.endsWith('/subscription')) return { subscriptions: [] }; + return null; + }); + + await ensureWebhookSubscriptions(marketplace); + + expect(makeRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + path: '/commerce/notification/v1/config', + }) + ); + }); +}); diff --git a/src/integrations/marketplaces/ebay/notifications.js b/src/integrations/marketplaces/ebay/notifications.js index 0f7c5ec..862ccc6 100644 --- a/src/integrations/marketplaces/ebay/notifications.js +++ b/src/integrations/marketplaces/ebay/notifications.js @@ -1,9 +1,13 @@ import crypto from 'crypto'; +import config from '../../../config.js'; import { getApiBaseUrl, getBasicAuthHeader, makeRequest, logger, formatDebugPayload, getMarketplaceDebugContext } from './shared.js'; +import { buildMarketplaceWebhookUrl } from '../webhookConfig.js'; const NOTIFICATION_BASE = '/commerce/notification/v1'; +const DEFAULT_APP_SCOPE = 'https://api.ebay.com/oauth/api_scope'; const APPLICATION_SCOPE = 'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription'; +const applicationTokenCache = new Map(); const HANDLED_TOPIC_MATCHERS = [ 'MARKETPLACE_ACCOUNT_DELETION', @@ -22,7 +26,7 @@ function getVerificationToken(marketplace) { } function getWebhookEndpoint(marketplace) { - return marketplace.config?.webhookUrl || ''; + return marketplace.config?.webhookUrl || buildMarketplaceWebhookUrl(marketplace) || ''; } export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) { @@ -42,8 +46,36 @@ export function buildWebhookChallengeResponse(marketplace, { challengeCode, endp return { challengeResponse }; } -async function getApplicationToken(marketplace) { - logger.debug('eBay application token request', getMarketplaceDebugContext(marketplace)); +function toPlainMarketplace(marketplace) { + if (!marketplace) { + return {}; + } + if (typeof marketplace.toObject === 'function') { + return marketplace.toObject(); + } + if (typeof marketplace.toJSON === 'function') { + return marketplace.toJSON(); + } + return { ...marketplace }; +} + +function applicationTokenCacheKey(marketplace, scope) { + const clientId = marketplace?.config?.clientId || ''; + const sandbox = marketplace?.config?.sandbox ? 'sandbox' : 'prod'; + return `${sandbox}:${clientId}:${scope}`; +} + +async function getApplicationToken(marketplace, scope = APPLICATION_SCOPE) { + const cacheKey = applicationTokenCacheKey(marketplace, scope); + const cached = applicationTokenCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now() + 30_000) { + return cached.accessToken; + } + + logger.debug('eBay application token request', { + ...getMarketplaceDebugContext(marketplace), + scope, + }); const startedAt = Date.now(); const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, { @@ -54,7 +86,7 @@ async function getApplicationToken(marketplace) { }, body: new URLSearchParams({ grant_type: 'client_credentials', - scope: APPLICATION_SCOPE, + scope, }).toString(), }); const data = await response.json(); @@ -64,6 +96,7 @@ async function getApplicationToken(marketplace) { status: response.status, durationMs, ...getMarketplaceDebugContext(marketplace), + scope, response: formatDebugPayload(data), }); throw new Error(data.error_description || data.error || 'Failed to mint eBay application token'); @@ -71,60 +104,43 @@ async function getApplicationToken(marketplace) { logger.debug(`eBay application token request succeeded (${durationMs}ms)`, { ...getMarketplaceDebugContext(marketplace), + scope, expiresIn: data.expires_in, tokenType: data.token_type, }); + applicationTokenCache.set(cacheKey, { + accessToken: data.access_token, + expiresAt: Date.now() + Number(data.expires_in || 7200) * 1000, + }); return data.access_token; } -async function notificationRequest(marketplace, { method = 'GET', path, body, useApplicationToken = false }) { - if (!useApplicationToken) { - return makeRequest({ marketplace, method, path, body, acceptableStatuses: [404] }); - } +async function notificationRequest( + marketplace, + { method = 'GET', path, body, useApplicationToken = false, applicationScope } = {} +) { + const plain = toPlainMarketplace(marketplace); + const requestMarketplace = { + ...plain, + name: marketplace?.name || plain.name, + config: { ...(plain.config || {}) }, + }; - const token = await getApplicationToken(marketplace); - const url = `${getApiBaseUrl(marketplace)}${path}`; - const startedAt = Date.now(); - logger.debug(`eBay notification API ${method} ${path}`, { - ...getMarketplaceDebugContext(marketplace), - useApplicationToken: true, - body: body ? formatDebugPayload(body) : undefined, - }); - - const response = await fetch(url, { - method, - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - ...(body ? { 'Content-Type': 'application/json' } : {}), - }, - ...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {}), - }); - const durationMs = Date.now() - startedAt; - if (response.status === 204 || response.status === 404) { - logger.debug( - `eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`, - getMarketplaceDebugContext(marketplace) + if (useApplicationToken) { + requestMarketplace.config.accessToken = await getApplicationToken( + requestMarketplace, + applicationScope || APPLICATION_SCOPE ); - return response.status === 404 ? null : null; - } - const data = await response.json().catch(() => null); - if (!response.ok) { - const message = data?.errors?.[0]?.message || data?.error_description || response.statusText; - logger.error(`eBay notification API error (${response.status}): ${message}`, { - path, - durationMs, - ...getMarketplaceDebugContext(marketplace), - response: data ? formatDebugPayload(data) : undefined, - }); - throw new Error(`eBay notification API error (${response.status}): ${message}`); } - logger.debug(`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`, { - ...getMarketplaceDebugContext(marketplace), - response: data ? formatDebugPayload(data) : undefined, + return makeRequest({ + marketplace: requestMarketplace, + method, + path, + body, + acceptableStatuses: [404], + marketplaceHeaders: false, }); - return data; } function topicIsHandled(topicId = '') { @@ -132,36 +148,127 @@ function topicIsHandled(topicId = '') { return HANDLED_TOPIC_MATCHERS.some((matcher) => id.includes(matcher)); } +function parseEmailAddress(value) { + const text = String(value || '').trim(); + if (!text) { + return ''; + } + const bracket = text.match(/<([^>]+)>/); + const email = (bracket?.[1] || text).trim(); + return email.includes('@') ? email : ''; +} + +export function resolveNotificationAlertEmail(marketplace) { + return ( + parseEmailAddress(marketplace?.config?.notificationAlertEmail) || + parseEmailAddress(config.smtp?.from) || + 'noreply@farmcontrol.app' + ); +} + +const CONFIG_PORTAL_HINT = + 'Set the alert email in the eBay Developer Portal (Application Keys → Alerts & Notifications), then retry Sync Webhooks.'; + +function isMissingNotificationConfigError(err) { + return /195003|configurations required for notifications/i.test(err?.message || ''); +} + +async function putNotificationConfig(marketplace, alertEmail) { + try { + await notificationRequest(marketplace, { + method: 'PUT', + path: `${NOTIFICATION_BASE}/config`, + useApplicationToken: true, + applicationScope: DEFAULT_APP_SCOPE, + body: { alertEmail }, + }); + return true; + } catch (err) { + logger.warn(`eBay notification config PUT with application token failed: ${err.message}`); + } + + try { + await notificationRequest(marketplace, { + method: 'PUT', + path: `${NOTIFICATION_BASE}/config`, + body: { alertEmail }, + }); + return true; + } catch (err) { + logger.warn(`eBay notification config PUT with user token failed: ${err.message}`); + return false; + } +} + +export async function ensureNotificationConfig(marketplace) { + const existing = await notificationRequest(marketplace, { + path: `${NOTIFICATION_BASE}/config`, + useApplicationToken: true, + applicationScope: DEFAULT_APP_SCOPE, + }); + if (existing?.alertEmail) { + return existing; + } + + const alertEmail = resolveNotificationAlertEmail(marketplace); + const updated = await putNotificationConfig(marketplace, alertEmail); + if (updated) { + logger.info(`eBay notification alert email set for "${marketplace.name}"`); + return { alertEmail }; + } + + logger.warn( + `Continuing webhook sync for "${marketplace.name}" without a stored eBay /config. ${CONFIG_PORTAL_HINT}` + ); + return { alertEmail, skipped: true }; +} + +function findDestinationByEndpoint(destinations, endpoint) { + return (destinations?.destinations || []).find((item) => item.deliveryConfig?.endpoint === endpoint); +} + export async function ensureWebhookSubscriptions(marketplace) { const endpoint = getWebhookEndpoint(marketplace); const verificationToken = getVerificationToken(marketplace); if (!endpoint || !verificationToken) { - logger.warn( - `Skipping eBay webhook subscription for "${marketplace.name}" (missing webhookUrl or verificationToken)` + throw new Error( + 'Marketplace webhook URL and verification token are required to subscribe to eBay notifications' ); - return { skipped: true }; } + await ensureNotificationConfig(marketplace); + const destinations = await notificationRequest(marketplace, { path: `${NOTIFICATION_BASE}/destination`, }); - let destination = (destinations?.destinations || []).find( - (item) => item.deliveryConfig?.endpoint === endpoint - ); + let destination = findDestinationByEndpoint(destinations, endpoint); if (!destination) { - destination = await notificationRequest(marketplace, { - method: 'POST', - path: `${NOTIFICATION_BASE}/destination`, - body: { - name: `FarmControl ${marketplace.name}`.slice(0, 200), - status: 'ENABLED', - deliveryConfig: { - endpoint, - verificationToken, + try { + destination = await notificationRequest(marketplace, { + method: 'POST', + path: `${NOTIFICATION_BASE}/destination`, + body: { + name: `FarmControl ${marketplace.name || ''}`.replace(/\s+/g, ' ').trim().slice(0, 64), + status: 'ENABLED', + deliveryConfig: { + endpoint, + verificationToken, + }, }, - }, - }); + }); + } catch (err) { + if (isMissingNotificationConfigError(err)) { + throw new Error(`${err.message}. ${CONFIG_PORTAL_HINT}`); + } + throw err; + } + if (!destination?.destinationId && !destination?.id) { + const refreshed = await notificationRequest(marketplace, { + path: `${NOTIFICATION_BASE}/destination`, + }); + destination = findDestinationByEndpoint(refreshed, endpoint) || destination; + } } const destinationId = destination?.destinationId || destination?.id; diff --git a/src/integrations/marketplaces/ebay/shared.js b/src/integrations/marketplaces/ebay/shared.js index 1e4212d..c6895b7 100644 --- a/src/integrations/marketplaces/ebay/shared.js +++ b/src/integrations/marketplaces/ebay/shared.js @@ -153,6 +153,7 @@ export async function makeRequest({ rawBody = false, contentType, baseUrl, + marketplaceHeaders = true, } = {}) { const { accessToken } = marketplace.config || {}; if (!accessToken) { @@ -171,11 +172,13 @@ export async function makeRequest({ const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', - 'Accept-Language': getAcceptLanguage(marketplace), ...extraHeaders, }; - headers['X-EBAY-C-MARKETPLACE-ID'] = getEbayMarketplaceId(marketplace); + if (marketplaceHeaders) { + headers['Accept-Language'] = getAcceptLanguage(marketplace); + headers['X-EBAY-C-MARKETPLACE-ID'] = getEbayMarketplaceId(marketplace); + } const fetchOptions = { method, @@ -191,7 +194,9 @@ export async function makeRequest({ fetchOptions.body = body; } else { fetchOptions.headers['Content-Type'] = 'application/json'; - fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace); + if (marketplaceHeaders) { + fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace); + } fetchOptions.body = JSON.stringify(body); } } diff --git a/src/integrations/marketplaces/webhookConfig.js b/src/integrations/marketplaces/webhookConfig.js new file mode 100644 index 0000000..74528ae --- /dev/null +++ b/src/integrations/marketplaces/webhookConfig.js @@ -0,0 +1,32 @@ +import crypto from 'crypto'; +import config from '../../config.js'; + +export function buildMarketplaceWebhookUrl(marketplace) { + const base = String(config.app?.urlApi || '').replace(/\/$/, ''); + if (!base || !marketplace?._id) { + return ''; + } + return `${base}/marketplaces/${marketplace._id}/hook`; +} + +export function generateWebhookVerificationToken() { + return crypto.randomBytes(32).toString('hex'); +} + +export function webhookConfigUpdates(marketplace) { + const existing = marketplace?.config || {}; + const updates = {}; + + if (!existing.webhookUrl) { + const webhookUrl = buildMarketplaceWebhookUrl(marketplace); + if (webhookUrl) { + updates.webhookUrl = webhookUrl; + } + } + + if (!existing.verificationToken) { + updates.verificationToken = generateWebhookVerificationToken(); + } + + return updates; +} diff --git a/src/integrations/marketplaceworker.js b/src/integrations/marketplaceworker.js index 74f5e00..bb6fc90 100644 --- a/src/integrations/marketplaceworker.js +++ b/src/integrations/marketplaceworker.js @@ -30,6 +30,7 @@ import { listingSyncHash, listingSyncUnchanged, } from './marketplaces/syncFingerprint.js'; +import { webhookConfigUpdates } from './marketplaces/webhookConfig.js'; const logger = log4js.getLogger('Marketplace Worker'); logger.level = config.server.logLevel; @@ -401,13 +402,27 @@ export async function syncTaxRateOutbound(marketplace, user, policyId) { }); } -export async function ensureWebhookSubscriptions(marketplace, user) { - const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user); - const provider = getProvider(authenticatedMarketplace); - if (typeof provider.ensureWebhookSubscriptions !== 'function') { - return { skipped: true }; +export async function syncWebhooks(marketplace, user) { + const configUpdates = webhookConfigUpdates(marketplace); + let readyMarketplace = marketplace; + if (Object.keys(configUpdates).length > 0) { + readyMarketplace = await persistMarketplaceUpdate(marketplace, configUpdates, {}, user); } - return provider.ensureWebhookSubscriptions(authenticatedMarketplace); + + const authenticatedMarketplace = await ensureMarketplaceAuth(readyMarketplace, user); + const provider = getProvider(authenticatedMarketplace); + const webhookUrl = authenticatedMarketplace.config?.webhookUrl; + if (typeof provider.ensureWebhookSubscriptions !== 'function') { + return { skipped: true, webhookUrl }; + } + return { + webhookUrl, + ...(await provider.ensureWebhookSubscriptions(authenticatedMarketplace)), + }; +} + +export async function ensureWebhookSubscriptions(marketplace, user) { + return syncWebhooks(marketplace, user); } export async function debugMarketplaceGet(marketplace, user, path, params = {}) { @@ -1173,9 +1188,10 @@ export async function runJob(action, payload = {}) { const marketplace = await loadMarketplace(payload.marketplaceId); return syncTaxRateOutbound(marketplace, user, payload.policyId); } - case 'ensureWebhookSubscriptions': { + case 'ensureWebhookSubscriptions': + case 'syncWebhooks': { const marketplace = await loadMarketplace(payload.marketplaceId); - return ensureWebhookSubscriptions(marketplace, user); + return syncWebhooks(marketplace, user); } case 'pushShipmentFulfillment': { const marketplace = await loadMarketplace(payload.marketplaceId); diff --git a/src/routes/sales/marketplaces.js b/src/routes/sales/marketplaces.js index 4480b63..8e6cdbd 100644 --- a/src/routes/sales/marketplaces.js +++ b/src/routes/sales/marketplaces.js @@ -54,6 +54,7 @@ import { syncMarketplacePaymentPoliciesRouteHandler, syncMarketplaceReturnPoliciesRouteHandler, syncMarketplaceTaxRatesRouteHandler, + syncMarketplaceWebhooksRouteHandler, marketplaceWebhookRouteHandler, marketplaceWebhookChallengeRouteHandler, subscribeMarketplaceWebhooksRouteHandler, @@ -222,6 +223,15 @@ router.post( } ); +router.post( + '/:id/sync/webhooks', + isAuthenticated, + checkPermissions('marketplace', 'sync'), + async (req, res) => { + syncMarketplaceWebhooksRouteHandler(req, res); + } +); + router.get('/:id/hook', async (req, res) => { marketplaceWebhookChallengeRouteHandler(req, res); }); diff --git a/src/services/sales/marketplaces.js b/src/services/sales/marketplaces.js index 6225681..217711a 100644 --- a/src/services/sales/marketplaces.js +++ b/src/services/sales/marketplaces.js @@ -1,5 +1,6 @@ import config from '../../config.js'; import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js'; +import { buildMarketplaceWebhookUrl } from '../../integrations/marketplaces/webhookConfig.js'; import log4js from 'log4js'; import mongoose from 'mongoose'; import { @@ -450,6 +451,38 @@ export const syncMarketplaceOrdersRouteHandler = async (req, res) => { res.send({ success: true, message: 'Order sync started' }); }; +export const syncMarketplaceWebhooksRouteHandler = async (req, res) => { + const id = req.params.id; + + const marketplace = await getObject({ model: marketplaceModel, id }); + if (marketplace?.error) { + logger.warn('Marketplace not found for webhook sync.'); + return res.status(marketplace.code).send(marketplace); + } + + if (!marketplace.active) { + return res.status(400).send({ error: 'Marketplace is not active.', code: 400 }); + } + + if (!marketplaceIntegration.hasIntegration(marketplace.provider)) { + return res.status(400).send({ + error: `No integration available for provider: ${marketplace.provider}`, + code: 400, + }); + } + + try { + const result = await marketplaceIntegration.syncWebhooks(marketplace, req.user, { + wait: true, + }); + logger.info(`Webhook sync completed for marketplace ${marketplace.name}`); + res.send({ success: true, message: 'Webhook sync completed', ...result }); + } catch (err) { + logger.error(`Error syncing webhooks for marketplace ${marketplace.name}:`, err.message); + res.status(400).send({ error: err.message, code: 400 }); + } +}; + export const marketplaceWebhookRouteHandler = async (req, res) => { const id = req.params.id; @@ -496,6 +529,7 @@ export const marketplaceWebhookChallengeRouteHandler = async (req, res) => { const challengeCode = req.query.challenge_code || req.query.challengeCode; const endpoint = marketplace.config?.webhookUrl || + buildMarketplaceWebhookUrl(marketplace) || `${req.protocol}://${req.get('host')}/marketplaces/${id}/hook`; try { @@ -522,7 +556,7 @@ export const subscribeMarketplaceWebhooksRouteHandler = async (req, res) => { return res.status(marketplace.code).send(marketplace); } try { - const result = await marketplaceIntegration.ensureWebhookSubscriptions(marketplace, req.user, { + const result = await marketplaceIntegration.syncWebhooks(marketplace, req.user, { wait: true, }); res.send({ success: true, ...result });