Added better listing and listing varient support.

This commit is contained in:
Tom Butcher 2026-03-21 21:39:10 +00:00
parent 250c404408
commit 1b858d8814
21 changed files with 3692 additions and 5 deletions

View File

@ -45,6 +45,7 @@ productSchema.statics.recalculate = async function (product, user) {
for (const orderItem of draftOrderItems) {
await orderItemModel.recalculate(orderItem, user);
}
};
// Create and export the model

View File

@ -39,6 +39,8 @@ import { invoiceModel } from './finance/invoice.schema.js';
import { clientModel } from './sales/client.schema.js';
import { salesOrderModel } from './sales/salesorder.schema.js';
import { marketplaceModel } from './sales/marketplace.schema.js';
import { listingModel } from './sales/listing.schema.js';
import { listingVarientModel } from './sales/listingvarient.schema.js';
// Map prefixes to models and id fields
export const models = {
@ -323,4 +325,18 @@ export const models = {
label: 'Marketplace',
referenceField: '_reference',
},
LST: {
model: listingModel,
idField: '_id',
type: 'listing',
label: 'Listing',
referenceField: '_reference',
},
LVR: {
model: listingVarientModel,
idField: '_id',
type: 'listingVarient',
label: 'Listing Varient',
referenceField: '_reference',
},
};

View File

@ -0,0 +1,42 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const listingSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
title: { type: String, required: false },
state: {
type: {
type: String,
enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'],
default: 'draft',
},
message: { type: String, required: false },
},
url: { type: String, required: false },
price: { type: Number, required: false },
currency: { type: String, required: false },
lastSyncedAt: { type: Date, required: false },
},
{ timestamps: true }
);
listingSchema.virtual('id').get(function () {
return this._id;
});
listingSchema.set('toJSON', {
virtuals: true,
transform(doc, ret) {
if (!ret.state && ret.status) {
ret.state = { type: ret.status, message: null };
}
if (ret.status) delete ret.status;
return ret;
},
});
export const listingModel = mongoose.model('listing', listingSchema);

View File

@ -0,0 +1,43 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const listingVarientSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: true },
product: { type: Schema.Types.ObjectId, ref: 'product', required: false },
productSku: { type: Schema.Types.ObjectId, ref: 'productSku', required: false },
state: {
type: {
type: String,
enum: ['draft', 'active', 'inactive', 'deleted', 'suspended', 'syncing'],
default: 'draft',
},
message: { type: String, required: false },
},
price: { type: Number, required: false },
currency: { type: String, required: false },
priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
priceWithTax: { type: Number, required: false },
lastSyncedAt: { type: Date, required: false },
},
{ timestamps: true }
);
listingVarientSchema.virtual('id').get(function () {
return this._id;
});
listingVarientSchema.set('toJSON', {
virtuals: true,
transform(doc, ret) {
if (!ret.state && ret.status) {
ret.state = { type: ret.status, message: null };
}
if (ret.status) delete ret.status;
return ret;
},
});
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);

View File

@ -1,4 +1,5 @@
import mongoose from 'mongoose';
import { editObject } from '../../database.js';
import { generateId } from '../../utils.js';
const marketplaceSchema = new mongoose.Schema(
@ -11,6 +12,16 @@ const marketplaceSchema = new mongoose.Schema(
enum: ['ebay', 'etsy', 'tiktokShop'],
},
active: { required: true, type: Boolean, default: true },
connected: { type: Boolean, required: true, default: false },
connectedAt: { type: Date, required: false },
state: {
type: {
type: String,
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing'],
default: 'offline',
},
message: { type: String, required: false },
},
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
config: { type: mongoose.Schema.Types.Mixed, default: {} },
},
@ -21,6 +32,26 @@ marketplaceSchema.virtual('id').get(function () {
return this._id;
});
marketplaceSchema.statics.recalculate = async function (marketplace, user) {
let stateType;
if (marketplace.active === false) {
stateType = 'inactive';
} else if (marketplace.connected === false) {
stateType = 'disconnected';
} else {
stateType = 'ready';
}
console.log('recalculating marketplace state', stateType);
marketplace.state = { type: stateType };
await editObject({
model: this,
id: marketplace._id,
updateData: { state: { type: stateType } },
user,
recalculate: false,
});
};
marketplaceSchema.set('toJSON', { virtuals: true });
export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);

View File

@ -47,6 +47,8 @@ import {
clientRoutes,
salesOrderRoutes,
marketplaceRoutes,
listingRoutes,
listingVarientRoutes,
userNotifierRoutes,
notificationRoutes,
odataRoutes,
@ -165,6 +167,8 @@ app.use('/payments', paymentRoutes);
app.use('/clients', clientRoutes);
app.use('/salesorders', salesOrderRoutes);
app.use('/marketplaces', marketplaceRoutes);
app.use('/listings', listingRoutes);
app.use('/listingvarients', listingVarientRoutes);
app.use('/notes', noteRoutes);
app.use('/usernotifiers', userNotifierRoutes);
app.use('/notifications', notificationRoutes);

View File

@ -0,0 +1,163 @@
import crypto from 'crypto';
import {
getApiBaseUrl,
getAuthorizeBaseUrl,
getScopes,
getScopesString,
getTokenExpiryDate,
isAccessTokenExpired,
getRequiredAuthConfig,
getBasicAuthHeader,
logger,
} from './shared.js';
const TOKEN_PATH = '/identity/v1/oauth2/token';
async function mintToken(marketplace, body) {
const response = await fetch(`${getApiBaseUrl(marketplace)}${TOKEN_PATH}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: getBasicAuthHeader(marketplace),
},
body: new URLSearchParams(body).toString(),
});
const data = await response.json();
if (!response.ok || data.error) {
const message = data.error_description || data.error || response.statusText;
logger.error(`eBay token request failed: ${message}`);
throw new Error(`eBay token request failed: ${message}`);
}
return data;
}
export function createAuthorizationUrl(marketplace, { state } = {}) {
const { clientId } = getRequiredAuthConfig(marketplace);
const { ruName, locale, prompt } = marketplace.config || {};
if (!ruName) {
throw new Error('eBay marketplace is missing required config (ruName)');
}
const url = new URL('/oauth2/authorize', getAuthorizeBaseUrl(marketplace));
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', ruName);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', getScopesString(marketplace));
if (state) {
url.searchParams.set('state', state);
}
if (locale) {
url.searchParams.set('locale', locale);
}
if (prompt) {
url.searchParams.set('prompt', prompt);
}
return url.toString();
}
export async function exchangeAuthorizationCode(marketplace, { code }) {
const { ruName } = marketplace.config || {};
if (!code) {
throw new Error('Missing eBay authorization code');
}
if (!ruName) {
throw new Error('eBay marketplace is missing required config (ruName)');
}
const tokenData = await mintToken(marketplace, {
grant_type: 'authorization_code',
code,
redirect_uri: ruName,
});
return {
configUpdates: {
accessToken: tokenData.access_token,
accessTokenExpiresAt: getTokenExpiryDate(tokenData.expires_in).toISOString(),
refreshToken: tokenData.refresh_token || marketplace.config.refreshToken,
scopes: getScopes(marketplace),
tokenType: tokenData.token_type,
},
marketplaceUpdates: {
connected: true,
connectedAt: new Date(),
},
data: {
expiresIn: tokenData.expires_in,
tokenType: tokenData.token_type,
},
};
}
export async function refreshAuth(marketplace) {
const { refreshToken } = marketplace.config || {};
if (!refreshToken) {
throw new Error('eBay marketplace is missing required config (refreshToken)');
}
const tokenData = await mintToken(marketplace, {
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope: getScopesString(marketplace),
});
return {
configUpdates: {
accessToken: tokenData.access_token,
accessTokenExpiresAt: getTokenExpiryDate(tokenData.expires_in).toISOString(),
refreshToken: tokenData.refresh_token || refreshToken,
scopes: getScopes(marketplace),
tokenType: tokenData.token_type,
lastTokenRefreshAt: new Date().toISOString(),
},
data: {
expiresIn: tokenData.expires_in,
tokenType: tokenData.token_type,
},
};
}
export async function ensureAuthenticatedMarketplace(marketplace) {
if (!isAccessTokenExpired(marketplace)) {
return { marketplace };
}
const authResult = await refreshAuth(marketplace);
return {
marketplace: {
...marketplace,
config: {
...(marketplace.config || {}),
...authResult.configUpdates,
},
},
configUpdates: authResult.configUpdates,
};
}
export function canVerifyWebhookSignature(marketplace) {
return !!marketplace.config?.verificationToken;
}
export function verifyWebhookSignature(marketplace, rawBody, signature) {
const verificationToken = marketplace.config?.verificationToken;
if (!verificationToken) {
return false;
}
const hash = crypto
.createHash('sha256')
.update(rawBody + verificationToken)
.digest('base64');
try {
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature));
} catch {
return false;
}
}

View File

@ -0,0 +1,28 @@
export {
createAuthorizationUrl,
exchangeAuthorizationCode,
refreshAuth,
ensureAuthenticatedMarketplace,
canVerifyWebhookSignature,
verifyWebhookSignature,
} from './auth.js';
export {
syncItems,
mapProductToListing,
createItem,
updateItem,
deleteItem,
publishOfferById,
withdrawOfferById,
publishOfferForSku,
withdrawOfferForSku,
} from './listings.js';
export {
syncOrders,
mapOrderStatus,
mapOrderToSalesOrder,
mapBuyerToClient,
handleWebhook,
} from './orders.js';

View File

@ -0,0 +1,524 @@
import { makeRequest, logger } from './shared.js';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function mapVarientToInventoryItem(varient, listing) {
const item = {
product: {
title: listing.title || varient._reference || '',
},
availability: {
shipToLocationAvailability: {
quantity: varient.inventory ?? 0,
},
},
};
if (listing.description) {
item.product.description = listing.description;
}
if (listing.imageUrls?.length) {
item.product.imageUrls = listing.imageUrls;
}
return item;
}
function mapVarientToOffer(varient, listing, marketplace) {
const offer = {
sku: varient._reference,
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
format: 'FIXED_PRICE',
};
const price = varient.price ?? listing.price;
if (price != null) {
offer.pricingSummary = {
price: {
value: String(price),
currency: varient.currency || listing.currency || 'GBP',
},
};
}
if (listing.categoryId) {
offer.categoryId = listing.categoryId;
}
return offer;
}
async function upsertInventoryItem(marketplace, varient, listing) {
const inventoryItem = mapVarientToInventoryItem(varient, listing);
const result = await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
body: inventoryItem,
});
console.log(result);
}
async function upsertOrCreateOffer(marketplace, varient, listing) {
const offers = await fetchOffers(marketplace, varient._reference);
const existingOffer = offers[0];
const price = varient.price ?? listing.price;
if (existingOffer?.offerId) {
const offerUpdate = {};
if (price != null) {
offerUpdate.pricingSummary = {
price: {
value: String(price),
currency: varient.currency || listing.currency || 'GBP',
},
};
}
if (Object.keys(offerUpdate).length > 0) {
await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/offer/${existingOffer.offerId}`,
body: { ...existingOffer, ...offerUpdate },
});
}
return existingOffer;
}
const offerBody = mapVarientToOffer(varient, listing, marketplace);
const result = await makeRequest({
marketplace,
method: 'POST',
path: '/sell/inventory/v1/offer',
body: offerBody,
});
return result;
}
async function createOrReplaceGroup(marketplace, listing, varients) {
const groupKey = listing._reference;
const variantSKUs = varients.map((v) => v._reference).filter(Boolean);
const body = {
title: listing.title || groupKey,
variantSKUs,
};
if (listing.description) {
body.description = listing.description;
}
if (listing.imageUrls?.length) {
body.imageUrls = listing.imageUrls;
}
await makeRequest({
marketplace,
method: 'PUT',
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
body,
});
}
async function deleteGroup(marketplace, groupKey) {
try {
await makeRequest({
marketplace,
method: 'DELETE',
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
acceptableStatuses: [404],
});
} catch (err) {
logger.warn(`Failed to delete inventory item group "${groupKey}": ${err.message}`);
}
}
async function syncOfferAndMaybePublish(marketplace, listing, varient) {
const offerResult = await upsertOrCreateOffer(marketplace, varient, listing);
if (offerResult?.offerId && listing.state?.type === 'active') {
try {
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
if (publishResult?.listingId) {
return `https://www.ebay.com/itm/${publishResult.listingId}`;
}
} catch (err) {
logger.warn(
`Created offer but failed to publish for varient ${varient._reference}: ${err.message}`
);
}
}
return '';
}
async function syncSingleVarientListing(marketplace, listing, varient) {
await upsertInventoryItem(marketplace, varient, listing);
// If this listing used to be grouped, remove the stale group before treating it as a standalone item.
if (listing._reference) {
const existingGroup = await safeFetchInventoryItemGroup(marketplace, listing._reference);
if (existingGroup) {
await deleteGroup(marketplace, listing._reference);
}
}
return syncOfferAndMaybePublish(marketplace, listing, varient);
}
async function syncGroupedListing(marketplace, listing, varients) {
logger.info(
`Syncing eBay inventory item group "${listing._reference}" with ${varients.length} varient(s)`
);
for (const varient of varients) {
await upsertInventoryItem(marketplace, varient, listing);
}
// Brief delay so eBay can resolve the new inventory item SKUs before creating the group.
await sleep(1000);
await createOrReplaceGroup(marketplace, listing, varients);
let firstPublishedUrl = '';
for (const varient of varients) {
try {
const publishedUrl = await syncOfferAndMaybePublish(marketplace, listing, varient);
if (publishedUrl && !firstPublishedUrl) firstPublishedUrl = publishedUrl;
} catch (err) {
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
}
}
return firstPublishedUrl;
}
async function syncListing(marketplace, listing, varients, actionLabel) {
const ref = listing._reference;
if (!ref) {
throw new Error(`Listing must have a _reference to ${actionLabel} on eBay`);
}
const validVarients = (varients || []).filter((varient) => varient?._reference);
if (validVarients.length === 0) {
throw new Error(
`Listing must have at least one varient with a _reference to ${actionLabel} on eBay`
);
}
if (validVarients.length === 1) {
logger.info(
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
);
const url = await syncSingleVarientListing(marketplace, listing, validVarients[0]);
return { url };
}
const url = await syncGroupedListing(marketplace, listing, validVarients);
return { url };
}
export async function createItem(marketplace, listing, varients) {
return syncListing(marketplace, listing, varients, 'create');
}
export async function updateItem(marketplace, listing, varients) {
return syncListing(marketplace, listing, varients, 'update');
}
export async function deleteItem(marketplace, listing) {
const ref = listing._reference;
if (!ref) return;
logger.info(`Deleting eBay inventory item group "${ref}"`);
await deleteGroup(marketplace, ref);
}
// --- Sync helpers (inbound from eBay) ---
async function fetchAllInventoryItems(marketplace) {
const items = [];
let offset = 0;
const limit = 100;
do {
const data = await makeRequest({
marketplace,
path: '/sell/inventory/v1/inventory_item',
params: { limit, offset },
});
if (data?.inventoryItems?.length) {
items.push(...data.inventoryItems);
}
if (!data?.inventoryItems?.length || items.length >= (data.total || 0)) {
break;
}
offset += limit;
} while (true);
return items;
}
async function fetchOffers(marketplace, sku) {
try {
const data = await makeRequest({
marketplace,
path: '/sell/inventory/v1/offer',
params: { sku, limit: 200 },
acceptableStatuses: [404],
});
return data?.offers || [];
} catch (err) {
logger.debug(`No offers found for SKU ${sku}: ${err.message}`);
return [];
}
}
/**
* eBay Sell Inventory API publish offer (creates live listing).
* @see https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/publishOffer
*/
export async function publishOfferById(marketplace, offerId) {
if (!offerId) {
throw new Error('offerId is required to publish an offer');
}
return makeRequest({
marketplace,
method: 'POST',
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`,
});
}
/**
* eBay Sell Inventory API withdraw offer (ends live listing; offer remains for re-publish).
* @see https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/withdrawOffer
*/
export async function withdrawOfferById(marketplace, offerId) {
if (!offerId) {
throw new Error('offerId is required to withdraw an offer');
}
return makeRequest({
marketplace,
method: 'POST',
path: `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`,
});
}
export async function publishOfferForSku(marketplace, sku) {
if (!sku) {
throw new Error('SKU (_reference) is required to publish an offer');
}
const offers = await fetchOffers(marketplace, sku);
const existingOffer = offers[0];
if (!existingOffer?.offerId) {
throw new Error(
`No eBay offer exists for SKU "${sku}". Create or sync the listing so an offer exists before publishing.`
);
}
const publishResult = await publishOfferById(marketplace, existingOffer.offerId);
return {
offerId: existingOffer.offerId,
listingId: publishResult?.listingId,
};
}
export async function withdrawOfferForSku(marketplace, sku) {
if (!sku) {
throw new Error('SKU (_reference) is required to withdraw an offer');
}
const offers = await fetchOffers(marketplace, sku);
const existingOffer = offers[0];
if (!existingOffer?.offerId) {
throw new Error(`No eBay offer exists for SKU "${sku}".`);
}
await withdrawOfferById(marketplace, existingOffer.offerId);
return { offerId: existingOffer.offerId };
}
async function safeFetchInventoryItemGroup(marketplace, groupKey) {
try {
return await makeRequest({
marketplace,
path: `/sell/inventory/v1/inventory_item_group/${encodeURIComponent(groupKey)}`,
acceptableStatuses: [404],
});
} catch (err) {
logger.warn(`Failed to fetch inventory item group "${groupKey}": ${err.message}`);
return null;
}
}
export async function syncItems(marketplace) {
logger.info(`Syncing inventory from eBay marketplace: ${marketplace.name}`);
const inventoryItems = await fetchAllInventoryItems(marketplace);
const itemsBySku = new Map();
const groupKeysSet = new Set();
const groupedSkus = new Set();
for (const item of inventoryItems) {
itemsBySku.set(item.sku, item);
if (item.inventoryItemGroupKeys?.length) {
for (const key of item.inventoryItemGroupKeys) {
groupKeysSet.add(key);
}
}
}
const results = [];
for (const groupKey of groupKeysSet) {
const group = await safeFetchInventoryItemGroup(marketplace, groupKey);
if (!group) continue;
const variantSkus = group.variantSKUs || [];
for (const sku of variantSkus) {
groupedSkus.add(sku);
}
const variantItems = [];
for (const sku of variantSkus) {
const item = itemsBySku.get(sku);
if (item) {
try {
const offers = await fetchOffers(marketplace, sku);
variantItems.push({ ...item, _offers: offers });
} catch (err) {
logger.warn(`Failed to fetch offers for group variant SKU ${sku}: ${err.message}`);
variantItems.push({ ...item, _offers: [] });
}
}
}
results.push({
_type: 'group',
_groupKey: groupKey,
_group: group,
_variants: variantItems,
});
}
for (const item of inventoryItems) {
if (groupedSkus.has(item.sku)) continue;
try {
const offers = await fetchOffers(marketplace, item.sku);
results.push({
_type: 'single',
...item,
_offers: offers,
});
} catch (err) {
logger.warn(`Failed to fetch offers for SKU ${item.sku}: ${err.message}`);
results.push({
_type: 'single',
...item,
_offers: [],
});
}
}
logger.info(
`Fetched ${results.length} listing(s) from eBay (${groupKeysSet.size} group(s), ${results.length - groupKeysSet.size} standalone)`
);
return results;
}
const LISTING_STATUS_MAP = {
ACTIVE: 'active',
OUT_OF_STOCK: 'inactive',
ENDED: 'inactive',
PUBLISHED: 'active',
UNPUBLISHED: 'draft',
};
function resolveOfferState(offers) {
let stateType = 'draft';
for (const offer of offers) {
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
const mapped = LISTING_STATUS_MAP[offer.status];
if (mapped === 'active') return 'active';
if (mapped !== 'draft') stateType = mapped;
}
}
return stateType;
}
function buildVarientEntry(item, offers) {
const offer = offers?.[0];
const price = offer?.pricingSummary?.price?.value
? parseFloat(offer.pricingSummary.price.value)
: undefined;
const currency = offer?.pricingSummary?.price?.currency || undefined;
return {
_reference: item.sku,
price,
currency,
state: { type: resolveOfferState(offers || []) },
};
}
export function mapProductToListing(ebayItem) {
if (ebayItem._type === 'group') {
const group = ebayItem._group;
const variants = ebayItem._variants || [];
const allOffers = variants.flatMap((v) => v._offers || []);
const stateType = resolveOfferState(allOffers);
const firstPublishedOffer = allOffers.find((o) => o?.listingId);
const url = firstPublishedOffer?.listingId
? `https://www.ebay.com/itm/${firstPublishedOffer.listingId}`
: '';
const firstOffer = allOffers[0];
const price = firstOffer?.pricingSummary?.price?.value
? parseFloat(firstOffer.pricingSummary.price.value)
: undefined;
const currency = firstOffer?.pricingSummary?.price?.currency || undefined;
const varients = variants.map((v) => buildVarientEntry(v, v._offers || []));
return {
_reference: ebayItem._groupKey,
title: group.title || ebayItem._groupKey,
state: { type: stateType },
price,
currency,
url,
varients,
};
}
const offer = ebayItem._offers?.[0];
const price = offer?.pricingSummary?.price?.value
? parseFloat(offer.pricingSummary.price.value)
: undefined;
const currency = offer?.pricingSummary?.price?.currency || undefined;
let stateType = 'draft';
if (offer?.status && LISTING_STATUS_MAP[offer.status]) {
stateType = LISTING_STATUS_MAP[offer.status];
}
const url = offer?.listingId ? `https://www.ebay.com/itm/${offer.listingId}` : '';
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
return {
_reference: ebayItem.sku,
title: ebayItem.product?.title || ebayItem.sku,
state: { type: stateType },
price,
currency,
url,
varients,
};
}

View File

@ -0,0 +1,156 @@
import { makeRequest, logger } from './shared.js';
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
const orders = [];
let offset = 0;
const limit = 50;
const filterParts = [];
if (startTime) {
const isoStart = new Date(startTime * 1000).toISOString();
filterParts.push(`creationdate:[${isoStart}..`);
}
if (endTime) {
const isoEnd = new Date(endTime * 1000).toISOString();
if (filterParts.length && filterParts[0].startsWith('creationdate:')) {
filterParts[0] = filterParts[0] + `${isoEnd}]`;
} else {
filterParts.push(`creationdate:[..${isoEnd}]`);
}
} else if (filterParts.length) {
filterParts[0] = filterParts[0] + ']';
}
do {
const params = { limit, offset };
if (filterParts.length) {
params.filter = filterParts.join(',');
}
const data = await makeRequest({
marketplace,
path: '/sell/fulfillment/v1/order',
params,
});
if (data?.orders?.length) {
orders.push(...data.orders);
}
if (!data?.orders?.length || orders.length >= (data.total || 0)) {
break;
}
offset += limit;
} while (true);
return orders;
}
export async function syncOrders(marketplace, { startTime, endTime } = {}) {
logger.info(`Syncing orders from eBay marketplace: ${marketplace.name}`);
const orders = await fetchAllOrders(marketplace, { startTime, endTime });
logger.info(`Fetched ${orders.length} order(s) from eBay`);
return orders;
}
const ORDER_STATUS_MAP = {
NOT_STARTED: 'draft',
IN_PROGRESS: 'confirmed',
FULFILLED: 'shipped',
CANCELLED: 'cancelled',
};
const FULFILLMENT_STATUS_MAP = {
NOT_STARTED: 'confirmed',
IN_PROGRESS: 'shipped',
FULFILLED: 'delivered',
};
export function mapOrderStatus(ebayOrder) {
if (ebayOrder.cancelStatus?.cancelState === 'CANCELED') {
return 'cancelled';
}
const fulfillmentStatus = ebayOrder.fulfillmentStartInstructions?.[0]?.fulfillmentStatus;
if (fulfillmentStatus && FULFILLMENT_STATUS_MAP[fulfillmentStatus]) {
return FULFILLMENT_STATUS_MAP[fulfillmentStatus];
}
return ORDER_STATUS_MAP[ebayOrder.orderFulfillmentStatus] || 'draft';
}
export function mapOrderToSalesOrder(ebayOrder) {
const pricingSummary = ebayOrder.pricingSummary || {};
const totalAmount = parseFloat(pricingSummary.priceSubtotal?.value || 0);
const shippingAmount = parseFloat(pricingSummary.deliveryCost?.value || 0);
const totalTax = parseFloat(pricingSummary.tax?.value || 0);
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
return {
externalId: ebayOrder.orderId,
state: { type: mapOrderStatus(ebayOrder) },
totalAmount,
totalAmountWithTax: totalAmount + totalTax,
shippingAmount,
shippingAmountWithTax: shippingAmount,
grandTotalAmount: grandTotal,
totalTaxAmount: totalTax,
};
}
export function mapBuyerToClient(ebayOrder) {
const buyer = ebayOrder.buyer || {};
const address = ebayOrder.fulfillmentStartInstructions?.[0]?.shippingStep?.shipTo || {};
const fullName = address.fullName || buyer.username || 'Unknown';
const contactAddress = address.contactAddress || {};
return {
name: fullName,
email: buyer.buyerRegistrationAddress?.email || '',
phone: address.primaryPhone?.phoneNumber || '',
address: {
addressLine1: contactAddress.addressLine1 || '',
addressLine2: contactAddress.addressLine2 || '',
city: contactAddress.city || '',
state: contactAddress.stateOrProvince || '',
postcode: contactAddress.postalCode || '',
country: contactAddress.countryCode || '',
},
};
}
export async function handleWebhook(marketplace, event) {
const { topic, data } = event;
logger.info(`eBay webhook received: ${topic} for marketplace ${marketplace.name}`);
if (topic?.startsWith('marketplace.account_deletion')) {
return { action: 'accountDeletion', userId: data?.userId };
}
switch (topic) {
case 'item.sold':
return { action: 'orderCreate', orderId: data?.orderId };
case 'item.created':
case 'item.updated':
return { action: 'productUpdate', itemId: data?.itemId };
case 'item.ended':
return { action: 'productEnded', itemId: data?.itemId };
case 'order.cancelled':
return { action: 'orderCancel', orderId: data?.orderId };
case 'order.fulfillment':
return { action: 'orderUpdate', orderId: data?.orderId };
default:
logger.debug(`Unhandled eBay webhook topic: ${topic}`);
return { action: 'unknown', topic };
}
}

View File

@ -0,0 +1,172 @@
import config from '../../../config.js';
import log4js from 'log4js';
const logger = log4js.getLogger('eBay');
logger.level = config.server.logLevel;
const SANDBOX_API_URL = 'https://api.sandbox.ebay.com';
const PRODUCTION_API_URL = 'https://api.ebay.com';
const SANDBOX_AUTH_URL = 'https://auth.sandbox.ebay.com';
const PRODUCTION_AUTH_URL = 'https://auth.ebay.com';
const TOKEN_PATH = '/identity/v1/oauth2/token';
const DEFAULT_SCOPES = [
'https://api.ebay.com/oauth/api_scope',
'https://api.ebay.com/oauth/api_scope/sell.inventory',
'https://api.ebay.com/oauth/api_scope/sell.fulfillment',
'https://api.ebay.com/oauth/api_scope/sell.account',
];
const MARKETPLACE_LANGUAGE_MAP = {
EBAY_US: 'en-US',
EBAY_GB: 'en-GB',
EBAY_AU: 'en-AU',
EBAY_CA: 'en-CA',
EBAY_DE: 'de-DE',
EBAY_FR: 'fr-FR',
EBAY_ES: 'es-ES',
EBAY_IT: 'it-IT',
EBAY_NL: 'nl-NL',
EBAY_BE: 'nl-BE',
};
export function getApiBaseUrl(marketplace) {
return marketplace.config.sandbox ? SANDBOX_API_URL : PRODUCTION_API_URL;
}
export function getAuthorizeBaseUrl(marketplace) {
return marketplace.config.sandbox ? SANDBOX_AUTH_URL : PRODUCTION_AUTH_URL;
}
export function getScopes(marketplace) {
if (Array.isArray(marketplace.config.scopes) && marketplace.config.scopes.length) {
return marketplace.config.scopes;
}
if (typeof marketplace.config.scopes === 'string' && marketplace.config.scopes.trim()) {
return marketplace.config.scopes.trim().split(/\s+/);
}
return DEFAULT_SCOPES;
}
export function getScopesString(marketplace) {
return getScopes(marketplace).join(' ');
}
function isValidLanguageTag(value) {
return /^[a-z]{2,3}(?:-[A-Z]{2})?$/.test(value || '');
}
export function getAcceptLanguage(marketplace) {
const configured =
marketplace.config?.acceptLanguage ||
marketplace.config?.locale ||
MARKETPLACE_LANGUAGE_MAP[marketplace.config?.marketplaceId];
if (typeof configured === 'string') {
const normalized = configured.replace('_', '-').trim();
if (isValidLanguageTag(normalized)) {
return normalized;
}
}
return 'en-GB';
}
export function getTokenExpiryDate(expiresInSeconds) {
return new Date(Date.now() + Math.max(Number(expiresInSeconds || 0) - 60, 0) * 1000);
}
export function isAccessTokenExpired(marketplace) {
const { accessToken, accessTokenExpiresAt } = marketplace.config || {};
if (!accessToken || !accessTokenExpiresAt) {
return true;
}
return new Date(accessTokenExpiresAt).getTime() <= Date.now();
}
export function getRequiredAuthConfig(marketplace) {
const { clientId, clientSecret } = marketplace.config || {};
if (!clientId || !clientSecret) {
throw new Error('eBay marketplace is missing required config (clientId, clientSecret)');
}
return { clientId, clientSecret };
}
export function getBasicAuthHeader(marketplace) {
const { clientId, clientSecret } = getRequiredAuthConfig(marketplace);
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
export async function makeRequest({
marketplace,
method = 'GET',
path,
params = {},
body = null,
acceptableStatuses = [],
} = {}) {
const { accessToken } = marketplace.config || {};
if (!accessToken) {
throw new Error(
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
);
}
const queryString = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const url = queryString
? `${getApiBaseUrl(marketplace)}${path}?${queryString}`
: `${getApiBaseUrl(marketplace)}${path}`;
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Accept-Language': getAcceptLanguage(marketplace),
};
if (marketplace.config.marketplaceId) {
headers['X-EBAY-C-MARKETPLACE-ID'] = marketplace.config.marketplaceId;
}
const fetchOptions = {
method,
headers,
};
if (body && method !== 'GET') {
fetchOptions.headers['Content-Type'] = 'application/json';
fetchOptions.headers['Content-Language'] = getAcceptLanguage(marketplace);
fetchOptions.body = JSON.stringify(body);
}
logger.debug(`eBay API ${method} ${path}`);
const response = await fetch(url, fetchOptions);
if (response.status === 204) {
return null;
}
const data = await response.json();
console.log('DATA: ' + JSON.stringify(data, null, 2));
if (!response.ok && acceptableStatuses.includes(response.status)) {
return null;
}
if (!response.ok) {
const message = data.errors?.[0]?.message || data.error_description || response.statusText;
logger.error(`eBay API error: ${message}`, { status: response.status, path });
throw new Error(`eBay API error (${response.status}): ${message}`);
}
return data;
}
export { logger };

View File

@ -0,0 +1,662 @@
import crypto from 'crypto';
import config from '../../config.js';
import log4js from 'log4js';
const logger = log4js.getLogger('TikTok Shop');
logger.level = config.server.logLevel;
const BASE_URL = 'https://open-api.tiktokglobalshop.com';
const AUTH_BASE_URL = 'https://auth.tiktok-shops.com';
const API_VERSION = '202309';
const AUTHORIZED_SHOPS_PATH = `/authorization/${API_VERSION}/shops`;
function getTokenExpiryDate(unixTimestampSeconds) {
return new Date(Number(unixTimestampSeconds || 0) * 1000);
}
function isAccessTokenExpired(marketplace) {
const { accessToken, accessTokenExpiresAt } = marketplace.config || {};
if (!accessToken || !accessTokenExpiresAt) {
return true;
}
return new Date(accessTokenExpiresAt).getTime() <= Date.now();
}
function shouldIncludeShopCipher(path, method = 'GET') {
if (/^\/product\/(\d{6})\/(compliance|global_products|files\/upload|images\/upload)/.test(path)) {
return false;
}
if (method === 'POST' && /^\/product\/(\d{6})\/brands/.test(path)) {
return false;
}
if (/^\/(authorization|seller)\/(\d{6})\//.test(path)) {
return false;
}
return true;
}
function generateSignature({ path, method, params, body, appSecret }) {
const paramsToBeSigned = { ...params };
delete paramsToBeSigned.sign;
delete paramsToBeSigned.access_token;
delete paramsToBeSigned['x-tts-access-token'];
const sortedKeys = Object.keys(paramsToBeSigned).sort();
let payload = path;
for (const key of sortedKeys) {
const value = paramsToBeSigned[key];
if (!Array.isArray(value)) {
payload += `${key}${value}`;
}
}
if (method !== 'GET' && body && typeof body === 'object') {
payload += JSON.stringify(body);
}
payload = `${appSecret}${payload}${appSecret}`;
return crypto.createHmac('sha256', appSecret).update(payload).digest('hex');
}
async function requestAuth(path, params) {
const url = new URL(path, AUTH_BASE_URL);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value);
}
}
const response = await fetch(url.toString(), { method: 'GET' });
const data = await response.json();
if (!response.ok || data.code !== 0) {
const message = data.message || response.statusText;
logger.error(`TikTok Shop auth error: ${message}`, { path, code: data.code });
throw new Error(`TikTok Shop auth error: ${message} (code: ${data.code ?? response.status})`);
}
return data.data;
}
async function makeRequest({ marketplace, method = 'GET', path, params = {}, body = null }) {
const { appKey, appSecret, accessToken, shopCipher } = marketplace.config || {};
if (!appKey || !appSecret || !accessToken) {
throw new Error(
'TikTok Shop marketplace is missing required config (appKey, appSecret, accessToken)'
);
}
const queryParams = {
...params,
app_key: appKey,
timestamp: Math.floor(Date.now() / 1000),
};
if (shopCipher && shouldIncludeShopCipher(path, method)) {
queryParams.shop_cipher = shopCipher;
}
const sign = generateSignature({
path,
method,
params: queryParams,
body,
appSecret,
});
queryParams.sign = sign;
const queryString = Object.entries(queryParams)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const url = `${BASE_URL}${path}?${queryString}`;
const fetchOptions = {
method,
headers: {
'Content-Type': 'application/json',
'x-tts-access-token': accessToken,
},
};
if (body && method !== 'GET') {
fetchOptions.body = JSON.stringify(body);
}
logger.debug(`TikTok Shop API ${method} ${path}`);
const response = await fetch(url, fetchOptions);
const data = await response.json();
if (data.code !== 0) {
logger.error(`TikTok Shop API error: ${data.message}`, { code: data.code, path });
throw new Error(`TikTok Shop API error: ${data.message} (code: ${data.code})`);
}
return data.data;
}
async function fetchAuthorizedShops(marketplace, accessToken) {
const authMarketplace = {
...marketplace,
config: {
...(marketplace.config || {}),
accessToken,
shopCipher: undefined,
},
};
const data = await makeRequest({
marketplace: authMarketplace,
method: 'GET',
path: AUTHORIZED_SHOPS_PATH,
});
if (Array.isArray(data?.shops)) {
return data.shops;
}
if (Array.isArray(data?.shop_list)) {
return data.shop_list;
}
if (Array.isArray(data)) {
return data;
}
return [];
}
function pickAuthorizedShop(marketplace, shops) {
if (!shops.length) {
return null;
}
const existingShopCipher = marketplace.config?.shopCipher;
const existingShopId = marketplace.config?.shopId;
const existingShopCode = marketplace.config?.shopCode;
return (
shops.find((shop) => shop.cipher === existingShopCipher) ||
shops.find((shop) => shop.id === existingShopId) ||
shops.find((shop) => shop.code === existingShopCode) ||
shops[0]
);
}
function getRequiredAuthConfig(marketplace) {
const { appKey, appSecret } = marketplace.config || {};
if (!appKey || !appSecret) {
throw new Error('TikTok Shop marketplace is missing required config (appKey, appSecret)');
}
return { appKey, appSecret };
}
function mapAuthConfigUpdates(marketplace, tokenData, shop) {
return {
accessToken: tokenData.access_token,
accessTokenExpiresAt: getTokenExpiryDate(tokenData.access_token_expire_in).toISOString(),
refreshToken: tokenData.refresh_token || marketplace.config.refreshToken,
refreshTokenExpiresAt: tokenData.refresh_token_expire_in
? getTokenExpiryDate(tokenData.refresh_token_expire_in).toISOString()
: marketplace.config.refreshTokenExpiresAt,
openId: tokenData.open_id || marketplace.config.openId,
grantedScopes: tokenData.granted_scopes || marketplace.config.grantedScopes || [],
sellerName: tokenData.seller_name || shop?.name || marketplace.config.sellerName,
sellerBaseRegion:
tokenData.seller_base_region || shop?.region || marketplace.config.sellerBaseRegion,
shopCipher: shop?.cipher || marketplace.config.shopCipher,
shopId: shop?.id || marketplace.config.shopId,
shopCode: shop?.code || marketplace.config.shopCode,
shopName: shop?.name || marketplace.config.shopName,
shopRegion: shop?.region || marketplace.config.shopRegion,
lastTokenRefreshAt: new Date().toISOString(),
};
}
export function createAuthorizationUrl(marketplace, { state } = {}) {
const { appKey } = getRequiredAuthConfig(marketplace);
const url = new URL('/oauth/authorize', AUTH_BASE_URL);
url.searchParams.set('app_key', appKey);
if (state) {
url.searchParams.set('state', state);
}
if (marketplace.config.redirectUri) {
url.searchParams.set('redirect_uri', marketplace.config.redirectUri);
}
return url.toString();
}
export async function exchangeAuthorizationCode(marketplace, { code }) {
const { appKey, appSecret } = getRequiredAuthConfig(marketplace);
if (!code) {
throw new Error('Missing TikTok Shop authorization code');
}
const tokenData = await requestAuth('/api/v2/token/get', {
app_key: appKey,
app_secret: appSecret,
auth_code: code,
grant_type: 'authorized_code',
});
const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token);
const selectedShop = pickAuthorizedShop(marketplace, shops);
return {
configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop),
marketplaceUpdates: {
connected: true,
connectedAt: new Date(),
},
data: {
shopCount: shops.length,
selectedShop,
},
};
}
export async function refreshAuth(marketplace) {
const { appKey, appSecret } = getRequiredAuthConfig(marketplace);
const { refreshToken } = marketplace.config || {};
if (!refreshToken) {
throw new Error('TikTok Shop marketplace is missing required config (refreshToken)');
}
const tokenData = await requestAuth('/api/v2/token/refresh', {
app_key: appKey,
app_secret: appSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
let selectedShop = null;
if (!marketplace.config?.shopCipher) {
const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token);
selectedShop = pickAuthorizedShop(marketplace, shops);
}
return {
configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop),
data: {
selectedShop,
},
};
}
export async function ensureAuthenticatedMarketplace(marketplace) {
if (!isAccessTokenExpired(marketplace)) {
return { marketplace };
}
const authResult = await refreshAuth(marketplace);
return {
marketplace: {
...marketplace,
config: {
...(marketplace.config || {}),
...authResult.configUpdates,
},
},
configUpdates: authResult.configUpdates,
};
}
async function fetchAllProducts(marketplace) {
const products = [];
let pageToken = '';
do {
const body = {
page_size: 100,
};
if (pageToken) {
body.page_token = pageToken;
}
const data = await makeRequest({
marketplace,
method: 'POST',
path: `/product/${API_VERSION}/products/search`,
body,
});
if (data.products?.length) {
products.push(...data.products);
}
pageToken = data.next_page_token || '';
} while (pageToken);
return products;
}
async function fetchProductDetail(marketplace, productId) {
return makeRequest({
marketplace,
method: 'GET',
path: `/product/${API_VERSION}/products/${productId}`,
params: { return_under_review_version: false },
});
}
async function fetchAllOrders(marketplace, { startTime, endTime } = {}) {
const orders = [];
let pageToken = '';
const now = Math.floor(Date.now() / 1000);
do {
const body = {
page_size: 50,
sort_field: 'CREATE_TIME',
sort_order: 'DESC',
};
if (startTime || endTime) {
body.create_time_ge = startTime || now - 86400 * 30;
body.create_time_lt = endTime || now;
}
if (pageToken) {
body.page_token = pageToken;
}
const data = await makeRequest({
marketplace,
method: 'POST',
path: `/order/${API_VERSION}/orders/search`,
body,
});
if (data.orders?.length) {
orders.push(...data.orders);
}
pageToken = data.next_page_token || '';
} while (pageToken);
return orders;
}
async function fetchOrderDetail(marketplace, orderId) {
return makeRequest({
marketplace,
method: 'GET',
path: `/order/${API_VERSION}/orders/${orderId}`,
});
}
function mapListingToProduct(listing) {
const product = {
title: listing.title || '',
is_cod_allowed: false,
};
if (listing.description) {
product.description = listing.description;
}
if (listing.categoryId) {
product.category_id = listing.categoryId;
}
if (listing.imageUrls?.length) {
product.main_images = listing.imageUrls.map((url) => ({ uri: url }));
}
const sku = {
inventory: [{ quantity: listing.inventory ?? 0 }],
};
if (listing.price != null) {
sku.price = {
amount: String(listing.price),
currency: listing.currency || 'GBP',
};
}
if (listing.externalId) {
sku.seller_sku = listing.externalId;
}
product.skus = [sku];
return product;
}
export async function createItem(marketplace, listing) {
logger.info(`Creating TikTok Shop product: ${listing.title}`);
const productBody = mapListingToProduct(listing);
const result = await makeRequest({
marketplace,
method: 'POST',
path: `/product/${API_VERSION}/products`,
body: productBody,
});
const productId = result?.product_id || result?.id;
return {
externalId: productId,
url: '',
};
}
export async function updateItem(marketplace, listing) {
const productId = listing.externalId;
if (!productId) {
throw new Error('Listing must have an externalId to update on TikTok Shop');
}
logger.info(`Updating TikTok Shop product: ${productId}`);
const productBody = mapListingToProduct(listing);
await makeRequest({
marketplace,
method: 'PUT',
path: `/product/${API_VERSION}/products/${productId}`,
body: productBody,
});
return { externalId: productId };
}
export async function syncItems(marketplace) {
logger.info(`Syncing products from TikTok Shop marketplace: ${marketplace.name}`);
const products = await fetchAllProducts(marketplace);
const detailed = [];
for (const product of products) {
try {
const detail = await fetchProductDetail(marketplace, product.id);
detailed.push(detail);
} catch (err) {
logger.warn(`Failed to fetch detail for product ${product.id}: ${err.message}`);
}
}
logger.info(`Fetched ${detailed.length} product(s) from TikTok Shop`);
return detailed;
}
export async function syncOrders(marketplace, { startTime, endTime } = {}) {
logger.info(`Syncing orders from TikTok Shop marketplace: ${marketplace.name}`);
const orders = await fetchAllOrders(marketplace, { startTime, endTime });
const detailed = [];
for (const order of orders) {
try {
const detail = await fetchOrderDetail(marketplace, order.id);
detailed.push(detail);
} catch (err) {
logger.warn(`Failed to fetch detail for order ${order.id}: ${err.message}`);
}
}
logger.info(`Fetched ${detailed.length} order(s) from TikTok Shop`);
return detailed;
}
const ORDER_STATUS_MAP = {
UNPAID: 'draft',
ON_HOLD: 'draft',
AWAITING_SHIPMENT: 'confirmed',
PARTIALLY_SHIPPING: 'partiallyShipped',
AWAITING_COLLECTION: 'shipped',
IN_TRANSIT: 'shipped',
DELIVERED: 'delivered',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
};
export function mapOrderStatus(tiktokStatus) {
return ORDER_STATUS_MAP[tiktokStatus] || 'draft';
}
export function mapOrderToSalesOrder(tiktokOrder) {
const paymentInfo = tiktokOrder.payment || {};
const totalAmount = parseFloat(paymentInfo.product_total_price || 0);
const shippingAmount = parseFloat(paymentInfo.shipping_fee || 0);
const totalTax = parseFloat(paymentInfo.tax || 0);
return {
externalId: tiktokOrder.id,
state: { type: mapOrderStatus(tiktokOrder.status) },
totalAmount,
totalAmountWithTax: totalAmount + totalTax,
shippingAmount,
shippingAmountWithTax: shippingAmount,
grandTotalAmount: parseFloat(paymentInfo.total_amount || 0),
totalTaxAmount: totalTax,
};
}
const PRODUCT_STATUS_MAP = {
DRAFT: 'draft',
PENDING: 'draft',
LIVE: 'active',
SELLER_DEACTIVATED: 'inactive',
PLATFORM_DEACTIVATED: 'suspended',
FROZEN: 'suspended',
DELETED: 'deleted',
};
export function mapProductToListing(tiktokProduct) {
const firstSku = tiktokProduct.skus?.[0];
const price = firstSku?.price?.sale_price
? parseFloat(firstSku.price.sale_price)
: firstSku?.price?.original_price
? parseFloat(firstSku.price.original_price)
: undefined;
const currency = firstSku?.price?.currency || undefined;
const totalInventory = (tiktokProduct.skus || []).reduce(
(sum, sku) => sum + (sku.inventory?.[0]?.quantity || 0),
0
);
const varients = (tiktokProduct.skus || []).map((sku) => {
const skuPrice = sku.price?.sale_price
? parseFloat(sku.price.sale_price)
: sku.price?.original_price
? parseFloat(sku.price.original_price)
: undefined;
return {
_reference: sku.id || sku.seller_sku || '',
price: skuPrice,
currency: sku.price?.currency || currency,
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
};
});
return {
_reference: tiktokProduct.id,
title: tiktokProduct.title || '',
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
price,
currency,
inventory: totalInventory,
url: tiktokProduct.url || '',
varients,
};
}
export function mapBuyerToClient(tiktokOrder) {
const buyer = tiktokOrder.buyer_info || {};
const address = tiktokOrder.recipient_address || {};
return {
name: buyer.name || address.name || 'Unknown',
email: buyer.email || '',
phone: buyer.phone_number || address.phone_number || '',
address: {
addressLine1: address.address_line1 || '',
addressLine2: address.address_line2 || '',
city: address.city || '',
state: address.state || '',
postcode: address.zipcode || '',
country: address.region_code || '',
},
};
}
export async function handleWebhook(marketplace, event) {
const { type, data } = event;
logger.info(`TikTok Shop webhook received: ${type} for marketplace ${marketplace.name}`);
switch (type) {
case 'ORDER_STATUS_CHANGE':
return { action: 'orderUpdate', orderId: data.order_id, status: data.order_status };
case 'ORDER_CREATION':
return { action: 'orderCreate', orderId: data.order_id };
case 'PRODUCT_STATUS_CHANGE':
return { action: 'productUpdate', productId: data.product_id, status: data.product_status };
case 'CANCELLATION_REQUEST':
return { action: 'orderCancel', orderId: data.order_id };
case 'RETURN_REQUEST':
return { action: 'returnRequest', orderId: data.order_id, returnId: data.return_id };
default:
logger.debug(`Unhandled TikTok Shop webhook type: ${type}`);
return { action: 'unknown', type };
}
}
export function canVerifyWebhookSignature(marketplace) {
return !!marketplace.config?.appSecret;
}
export function verifyWebhookSignature(marketplace, body, signature) {
const appSecret = marketplace.config?.appSecret;
if (!appSecret) {
return false;
}
const computed = crypto.createHmac('sha256', appSecret).update(body).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
} catch {
return false;
}
}

View File

@ -0,0 +1,588 @@
import config from '../config.js';
import log4js from 'log4js';
import { clientModel } from '../database/schemas/sales/client.schema.js';
import { salesOrderModel } from '../database/schemas/sales/salesorder.schema.js';
import { listingModel } from '../database/schemas/sales/listing.schema.js';
import { listingVarientModel } from '../database/schemas/sales/listingvarient.schema.js';
import { marketplaceModel } from '../database/schemas/sales/marketplace.schema.js';
import { editObject, newObject } from '../database/database.js';
import * as tiktokShop from './marketplaces/tiktokShop.js';
import * as ebay from './marketplaces/ebay/index.js';
const logger = log4js.getLogger('Marketplace Worker');
logger.level = config.server.logLevel;
const providers = {
tiktokShop,
ebay,
};
function getProvider(marketplace) {
const provider = providers[marketplace.provider];
if (!provider) {
throw new Error(`No integration available for provider: ${marketplace.provider}`);
}
return provider;
}
export function hasIntegration(provider) {
return !!providers[provider];
}
export async function publishMarketplaceOfferForSku(marketplace, user, sku) {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
if (typeof provider.publishOfferForSku !== 'function') {
throw new Error(
`Marketplace provider "${marketplace.provider}" does not support publishing offers`
);
}
return provider.publishOfferForSku(authenticatedMarketplace, sku);
}
export async function withdrawMarketplaceOfferForSku(marketplace, user, sku) {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const provider = getProvider(authenticatedMarketplace);
if (typeof provider.withdrawOfferForSku !== 'function') {
throw new Error(
`Marketplace provider "${marketplace.provider}" does not support withdrawing offers`
);
}
return provider.withdrawOfferForSku(authenticatedMarketplace, sku);
}
async function persistMarketplaceUpdate(marketplace, configUpdates, marketplaceUpdates, user) {
const updateData = { updatedAt: new Date() };
if (configUpdates && Object.keys(configUpdates).length > 0) {
updateData.config = {
...(marketplace.config || {}),
...configUpdates,
};
}
if (marketplaceUpdates && Object.keys(marketplaceUpdates).length > 0) {
Object.assign(updateData, marketplaceUpdates);
}
if (Object.keys(updateData).length <= 1) {
return marketplace;
}
return editObject({
model: marketplaceModel,
id: marketplace._id,
updateData,
user,
});
}
async function ensureMarketplaceAuth(marketplace, user) {
const provider = getProvider(marketplace);
if (!provider.ensureAuthenticatedMarketplace) {
return marketplace;
}
const authResult = await provider.ensureAuthenticatedMarketplace(marketplace);
if (!authResult?.configUpdates) {
return authResult?.marketplace || marketplace;
}
return persistMarketplaceUpdate(
marketplace,
authResult.configUpdates,
authResult.marketplaceUpdates,
user
);
}
export function canAuthorize(marketplace) {
const provider = getProvider(marketplace);
return (
typeof provider.createAuthorizationUrl === 'function' &&
typeof provider.exchangeAuthorizationCode === 'function'
);
}
export function getAuthorizationUrl(marketplace, { state } = {}) {
const provider = getProvider(marketplace);
if (!provider.createAuthorizationUrl) {
throw new Error(`Provider ${marketplace.provider} does not support marketplace authorization`);
}
return provider.createAuthorizationUrl(marketplace, { state });
}
export async function exchangeAuthorizationCode(marketplace, user, { code, state } = {}) {
const provider = getProvider(marketplace);
if (!provider.exchangeAuthorizationCode) {
throw new Error(`Provider ${marketplace.provider} does not support marketplace authorization`);
}
const authResult = await provider.exchangeAuthorizationCode(marketplace, { code, state });
const updatedMarketplace = await persistMarketplaceUpdate(
marketplace,
authResult?.configUpdates || {},
authResult?.marketplaceUpdates,
user
);
return {
marketplace: updatedMarketplace,
...(authResult?.data ? { data: authResult.data } : {}),
};
}
export async function refreshMarketplaceAuth(marketplace, user) {
const provider = getProvider(marketplace);
if (!provider.refreshAuth) {
throw new Error(`Provider ${marketplace.provider} does not support token refresh`);
}
const authResult = await provider.refreshAuth(marketplace);
const updatedMarketplace = await persistMarketplaceUpdate(
marketplace,
authResult?.configUpdates || {},
authResult?.marketplaceUpdates,
user
);
return {
marketplace: updatedMarketplace,
...(authResult?.data ? { data: authResult.data } : {}),
};
}
export function canVerifyWebhookSignature(marketplace) {
const provider = getProvider(marketplace);
if (typeof provider.verifyWebhookSignature !== 'function') {
return false;
}
if (typeof provider.canVerifyWebhookSignature === 'function') {
return provider.canVerifyWebhookSignature(marketplace);
}
return true;
}
export function verifyWebhookSignature(marketplace, rawBody, signature) {
const provider = getProvider(marketplace);
if (!provider.verifyWebhookSignature) {
logger.warn(`Provider ${marketplace.provider} does not support webhook signature verification`);
return true;
}
return provider.verifyWebhookSignature(marketplace, rawBody, signature);
}
export async function handleWebhook(marketplace, event) {
const provider = getProvider(marketplace);
return provider.handleWebhook(marketplace, event);
}
async function setListingState(listingId, stateType, user, message) {
const state = { type: stateType };
if (message) state.message = message;
return editObject({
model: listingModel,
id: listingId,
updateData: { state },
user,
recalculate: false,
});
}
async function setListingVarientState(varientId, stateType, user, message) {
const state = { type: stateType };
if (message) state.message = message;
return editObject({
model: listingVarientModel,
id: varientId,
updateData: { state },
user,
recalculate: false,
});
}
async function setMarketplaceState(marketplaceId, stateType, user, message) {
const state = { type: stateType };
if (message) state.message = message;
return editObject({
model: marketplaceModel,
id: marketplaceId,
updateData: { state },
user,
recalculate: false,
});
}
async function recalculateMarketplaceState(marketplace, user) {
await marketplaceModel.recalculate(marketplace, user);
}
async function fetchFullListing(listingId) {
return listingModel.findById(listingId).lean();
}
async function fetchListingVarients(listingId) {
return listingVarientModel.find({ listing: listingId }).lean();
}
export function createListing(marketplace, user, listingData) {
const provider = getProvider(marketplace);
if (!provider.createItem) {
logger.debug(`Provider ${marketplace.provider} does not support createItem — skipping`);
return;
}
if (listingData._id) {
setListingState(listingData._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set listing syncing state: ${err.message}`)
);
}
const work = async () => {
try {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
if (!fullListing) throw new Error('Listing not found');
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
logger.info(
`Creating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
);
const result = await provider.createItem(authenticatedMarketplace, fullListing, varients);
if (listingData._id) {
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
if (result?.url) updateData.url = result.url;
await editObject({ model: listingModel, id: listingData._id, updateData, user });
for (const varient of varients) {
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
user,
}).catch(() => {});
}
}
logger.info(`Background createListing complete for marketplace "${marketplace.name}"`);
} catch (err) {
logger.error(
`Background createListing failed for marketplace "${marketplace.name}": ${err.message}`
);
if (listingData._id) {
await setListingState(listingData._id, 'draft', user, err.message).catch(() => {});
}
}
};
work();
}
export function updateListing(marketplace, user, listingData) {
const provider = getProvider(marketplace);
if (!provider.updateItem) {
logger.debug(`Provider ${marketplace.provider} does not support updateItem — skipping`);
return;
}
if (listingData._id) {
setListingState(listingData._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set listing syncing state: ${err.message}`)
);
}
const work = async () => {
try {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
const fullListing = listingData._id ? await fetchFullListing(listingData._id) : listingData;
if (!fullListing) throw new Error('Listing not found');
const varients = listingData._id ? await fetchListingVarients(listingData._id) : [];
logger.info(
`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
);
await provider.updateItem(authenticatedMarketplace, fullListing, varients);
if (listingData._id) {
await editObject({
model: listingModel,
id: listingData._id,
updateData: { state: { type: 'active' }, lastSyncedAt: new Date() },
user,
});
for (const varient of varients) {
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
user,
}).catch(() => {});
}
}
logger.info(`Background updateListing complete for marketplace "${marketplace.name}"`);
} catch (err) {
logger.error(
`Background updateListing failed for marketplace "${marketplace.name}": ${err.message}`
);
if (listingData._id) {
await setListingState(listingData._id, 'active', user, err.message).catch(() => {});
}
}
};
work();
}
export function deleteListing(marketplace, user, listingData) {
const provider = getProvider(marketplace);
if (!provider.deleteItem) {
logger.debug(`Provider ${marketplace.provider} does not support deleteItem — skipping`);
return;
}
const work = async () => {
try {
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
logger.info(
`Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})`
);
await provider.deleteItem(authenticatedMarketplace, listingData);
logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`);
} catch (err) {
logger.error(
`Background deleteListing failed for marketplace "${marketplace.name}": ${err.message}`
);
}
};
work();
}
export function syncItems(marketplace, user) {
setMarketplaceState(marketplace._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set marketplace syncing state: ${err.message}`)
);
const work = async () => {
try {
const provider = getProvider(marketplace);
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
logger.info(
`Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})`
);
const existingListings = await listingModel
.find({
marketplace: authenticatedMarketplace._id,
'state.type': { $ne: 'deleted' },
})
.lean();
for (const listing of existingListings) {
await setListingState(listing._id, 'syncing', user).catch(() => {});
}
const existingVarients = await listingVarientModel
.find({
listing: { $in: existingListings.map((l) => l._id) },
})
.lean();
for (const varient of existingVarients) {
await setListingVarientState(varient._id, 'syncing', user).catch(() => {});
}
const results = [];
for (const listing of existingListings) {
try {
const listingVarients = existingVarients.filter(
(varient) => String(varient.listing) === String(listing._id)
);
if (!listingVarients.length) {
throw new Error('Listing has no varients to sync');
}
const result = await provider.updateItem(
authenticatedMarketplace,
listing,
listingVarients
);
const listingUpdateData = {
lastSyncedAt: new Date(),
state: listing.state || { type: 'draft' },
};
if (result?.url) {
listingUpdateData.url = result.url;
}
await editObject({
model: listingModel,
id: listing._id,
updateData: listingUpdateData,
user,
});
for (const varient of listingVarients) {
await editObject({
model: listingVarientModel,
id: varient._id,
updateData: {
lastSyncedAt: new Date(),
state: varient.state || { type: 'draft' },
},
user,
}).catch(() => {});
}
results.push({ _reference: listing._reference, action: 'synced', id: listing._id });
} catch (err) {
logger.warn(`Failed to sync listing ${listing._reference}: ${err.message}`);
await setListingState(
listing._id,
listing.state?.type || 'draft',
user,
err.message
).catch(() => {});
results.push({
_reference: listing._reference,
action: 'error',
error: err.message,
});
}
}
logger.info(
`Item sync complete for marketplace ${marketplace.name}: ${results.length} processed`
);
await recalculateMarketplaceState(marketplace, user);
} catch (err) {
logger.error(
`Background syncItems failed for marketplace "${marketplace.name}": ${err.message}`
);
await recalculateMarketplaceState(marketplace, user);
}
};
work();
}
export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
setMarketplaceState(marketplace._id, 'syncing', user).catch((err) =>
logger.warn(`Failed to set marketplace syncing state: ${err.message}`)
);
const work = async () => {
try {
const provider = getProvider(marketplace);
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
logger.info(
`Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})`
);
const externalOrders = await provider.syncOrders(authenticatedMarketplace, {
startTime,
endTime,
});
const results = [];
for (const externalOrder of externalOrders) {
try {
const mapped = provider.mapOrderToSalesOrder(externalOrder);
const clientData = provider.mapBuyerToClient(externalOrder);
let client = await clientModel.findOne({
name: clientData.name,
marketplace: authenticatedMarketplace._id,
});
if (!client) {
client = await newObject({
model: clientModel,
newData: {
...clientData,
marketplace: authenticatedMarketplace._id,
active: true,
},
user,
});
logger.debug(`Created client "${clientData.name}" from marketplace order`);
}
const existingOrder = await salesOrderModel.findOne({
marketplace: authenticatedMarketplace._id,
_reference: mapped.externalId,
});
if (existingOrder) {
await editObject({
model: salesOrderModel,
id: existingOrder._id,
updateData: {
state: mapped.state,
totalAmount: mapped.totalAmount,
totalAmountWithTax: mapped.totalAmountWithTax,
shippingAmount: mapped.shippingAmount,
shippingAmountWithTax: mapped.shippingAmountWithTax,
grandTotalAmount: mapped.grandTotalAmount,
totalTaxAmount: mapped.totalTaxAmount,
updatedAt: new Date(),
},
user,
});
results.push({
externalId: mapped.externalId,
action: 'updated',
id: existingOrder._id,
});
} else {
const salesOrder = await newObject({
model: salesOrderModel,
newData: {
_reference: mapped.externalId,
client: client._id,
marketplace: authenticatedMarketplace._id,
state: mapped.state,
totalAmount: mapped.totalAmount,
totalAmountWithTax: mapped.totalAmountWithTax,
shippingAmount: mapped.shippingAmount,
shippingAmountWithTax: mapped.shippingAmountWithTax,
grandTotalAmount: mapped.grandTotalAmount,
totalTaxAmount: mapped.totalTaxAmount,
},
user,
});
results.push({ externalId: mapped.externalId, action: 'created', id: salesOrder._id });
}
} catch (err) {
logger.warn(`Failed to process order: ${err.message}`);
results.push({ externalId: externalOrder.id, action: 'error', error: err.message });
}
}
logger.info(
`Order sync complete for marketplace ${marketplace.name}: ${results.length} processed`
);
await recalculateMarketplaceState(marketplace, user);
} catch (err) {
logger.error(
`Background syncOrders failed for marketplace "${marketplace.name}": ${err.message}`
);
await recalculateMarketplaceState(marketplace, user);
}
};
work();
}

View File

@ -39,6 +39,8 @@ import paymentRoutes from './finance/payments.js';
import clientRoutes from './sales/clients.js';
import salesOrderRoutes from './sales/salesorders.js';
import marketplaceRoutes from './sales/marketplaces.js';
import listingRoutes from './sales/listings.js';
import listingVarientRoutes from './sales/listingvarients.js';
import noteRoutes from './misc/notes.js';
import userNotifierRoutes from './misc/usernotifiers.js';
import notificationRoutes from './misc/notifications.js';
@ -89,6 +91,8 @@ export {
clientRoutes,
salesOrderRoutes,
marketplaceRoutes,
listingRoutes,
listingVarientRoutes,
userNotifierRoutes,
notificationRoutes,
odataRoutes,

View File

@ -0,0 +1,65 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listListingsRouteHandler,
getListingRouteHandler,
editListingRouteHandler,
newListingRouteHandler,
deleteListingRouteHandler,
listListingsByPropertiesRouteHandler,
getListingStatsRouteHandler,
getListingHistoryRouteHandler,
publishListingRouteHandler,
unpublishListingRouteHandler,
} from '../../services/sales/listings.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['product', 'marketplace', 'state', 'state.type', 'createdAt', 'updatedAt'];
const filter = getFilter(req.query, allowedFilters);
listListingsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['product', 'marketplace', 'state', 'state.type', 'createdAt', 'updatedAt'];
const filter = getFilter(req.query, allowedFilters, false);
listListingsByPropertiesRouteHandler(req, res, properties, filter);
});
router.post('/', isAuthenticated, (req, res) => {
newListingRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getListingStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getListingHistoryRouteHandler(req, res);
});
router.post('/:id/publish', isAuthenticated, (req, res) => {
publishListingRouteHandler(req, res);
});
router.post('/:id/unpublish', isAuthenticated, (req, res) => {
unpublishListingRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getListingRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editListingRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteListingRouteHandler(req, res);
});
export default router;

View File

@ -0,0 +1,83 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { getFilter, convertPropertiesString } from '../../utils.js';
const router = express.Router();
import {
listListingVarientsRouteHandler,
getListingVarientRouteHandler,
editListingVarientRouteHandler,
newListingVarientRouteHandler,
deleteListingVarientRouteHandler,
listListingVarientsByPropertiesRouteHandler,
getListingVarientStatsRouteHandler,
getListingVarientHistoryRouteHandler,
publishListingVarientRouteHandler,
unpublishListingVarientRouteHandler,
} from '../../services/sales/listingvarients.js';
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = [
'listing',
'listing._id',
'product',
'productSku',
'state',
'state.type',
'createdAt',
'updatedAt',
];
const filter = getFilter(req.query, allowedFilters);
listListingVarientsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = [
'listing',
'listing._id',
'product',
'productSku',
'state',
'state.type',
'createdAt',
'updatedAt',
];
const filter = getFilter(req.query, allowedFilters, false);
listListingVarientsByPropertiesRouteHandler(req, res, properties, filter);
});
router.post('/', isAuthenticated, (req, res) => {
newListingVarientRouteHandler(req, res);
});
router.get('/stats', isAuthenticated, (req, res) => {
getListingVarientStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, (req, res) => {
getListingVarientHistoryRouteHandler(req, res);
});
router.post('/:id/publish', isAuthenticated, (req, res) => {
publishListingVarientRouteHandler(req, res);
});
router.post('/:id/unpublish', isAuthenticated, (req, res) => {
unpublishListingVarientRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getListingVarientRouteHandler(req, res);
});
router.put('/:id', isAuthenticated, async (req, res) => {
editListingVarientRouteHandler(req, res);
});
router.delete('/:id', isAuthenticated, async (req, res) => {
deleteListingVarientRouteHandler(req, res);
});
export default router;

View File

@ -12,19 +12,24 @@ import {
listMarketplacesByPropertiesRouteHandler,
getMarketplaceStatsRouteHandler,
getMarketplaceHistoryRouteHandler,
getMarketplaceAuthUrlRouteHandler,
exchangeMarketplaceAuthCodeRouteHandler,
refreshMarketplaceAuthRouteHandler,
syncMarketplaceItemsRouteHandler,
syncMarketplaceOrdersRouteHandler,
marketplaceWebhookRouteHandler,
} from '../../services/sales/marketplaces.js';
// list of marketplaces
router.get('/', isAuthenticated, (req, res) => {
const { page, limit, property, search, sort, order } = req.query;
const allowedFilters = ['name', 'provider', 'active', 'createdAt', 'updatedAt'];
const allowedFilters = ['name', 'provider', 'active', 'connected', 'state.type', 'createdAt', 'updatedAt'];
const filter = getFilter(req.query, allowedFilters);
listMarketplacesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
});
router.get('/properties', isAuthenticated, (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const allowedFilters = ['name', 'provider', 'active', 'createdAt', 'updatedAt'];
const allowedFilters = ['name', 'provider', 'active', 'connected', 'state.type', 'createdAt', 'updatedAt'];
const filter = getFilter(req.query, allowedFilters, false);
listMarketplacesByPropertiesRouteHandler(req, res, properties, filter);
});
@ -33,16 +38,39 @@ router.post('/', isAuthenticated, (req, res) => {
newMarketplaceRouteHandler(req, res);
});
// get marketplace stats
router.get('/stats', isAuthenticated, (req, res) => {
getMarketplaceStatsRouteHandler(req, res);
});
// get marketplaces history
router.get('/history', isAuthenticated, (req, res) => {
getMarketplaceHistoryRouteHandler(req, res);
});
router.get('/:id/auth/url', isAuthenticated, (req, res) => {
getMarketplaceAuthUrlRouteHandler(req, res);
});
router.post('/:id/auth/exchange', isAuthenticated, (req, res) => {
exchangeMarketplaceAuthCodeRouteHandler(req, res);
});
router.post('/:id/auth/refresh', isAuthenticated, (req, res) => {
refreshMarketplaceAuthRouteHandler(req, res);
});
router.post('/:id/sync/items', isAuthenticated, (req, res) => {
syncMarketplaceItemsRouteHandler(req, res);
});
router.post('/:id/sync/orders', isAuthenticated, (req, res) => {
syncMarketplaceOrdersRouteHandler(req, res);
});
// Webhook endpoint — no auth, provider verifies via signature
router.post('/:id/hook', (req, res) => {
marketplaceWebhookRouteHandler(req, res);
});
router.get('/:id', isAuthenticated, (req, res) => {
getMarketplaceRouteHandler(req, res);
});

View File

@ -0,0 +1,459 @@
import config from '../../config.js';
import { listingModel } from '../../database/schemas/sales/listing.schema.js';
import { listingVarientModel } from '../../database/schemas/sales/listingvarient.schema.js';
import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import {
deleteObject,
listObjects,
getObject,
editObject,
newObject,
listObjectsByProperties,
getModelStats,
getModelHistory,
checkStates,
} from '../../database/database.js';
import {
hasIntegration,
createListing as createExternalListing,
updateListing as updateExternalListing,
deleteListing as deleteExternalListing,
publishMarketplaceOfferForSku,
withdrawMarketplaceOfferForSku,
} from '../../integrations/marketplaceworker.js';
const logger = log4js.getLogger('Listings');
logger.level = config.server.logLevel;
function pushToMarketplace(marketplaceId, listingData, user, { isNew = false, isDelete = false } = {}) {
const run = async () => {
try {
const marketplace = await marketplaceModel.findById(marketplaceId);
if (!marketplace || !marketplace.active || !hasIntegration(marketplace.provider)) {
return;
}
if (isDelete) {
deleteExternalListing(marketplace, user, listingData);
} else if (isNew) {
createExternalListing(marketplace, user, listingData);
} else {
updateExternalListing(marketplace, user, listingData);
}
} catch (err) {
logger.warn(`Failed to initiate marketplace sync for listing: ${err.message}`);
}
};
run();
}
export const listListingsRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: listingModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: ['product', 'marketplace'],
});
if (result?.error) {
logger.error('Error listing listings.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of listings (Page ${page}, Limit ${limit}). Count: ${result.length}.`);
res.send(result);
};
export const listListingsByPropertiesRouteHandler = async (
req,
res,
properties = '',
filter = {}
) => {
const result = await listObjectsByProperties({
model: listingModel,
properties,
filter,
populate: ['product', 'marketplace'],
});
if (result?.error) {
logger.error('Error listing listings.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of listings. Count: ${result.length}`);
res.send(result);
};
export const getListingRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: listingModel,
id,
populate: ['product', 'marketplace'],
});
if (result?.error) {
logger.warn(`Listing not found with supplied id.`);
return res.status(result.code).send(result);
}
logger.debug(`Retrieved listing with ID: ${id}`);
res.send(result);
};
export const editListingRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`Listing with ID: ${id}`);
const updateData = {
updatedAt: new Date(),
product: req.body.product,
marketplace: req.body.marketplace,
title: req.body.title,
url: req.body.url,
};
const result = await editObject({
model: listingModel,
id,
updateData,
user: req.user,
populate: ['product', 'marketplace'],
});
if (result.error) {
logger.error('Error editing listing:', result.error);
res.status(result.code).send(result);
return;
}
const marketplaceId = result.marketplace?._id || result.marketplace;
if (marketplaceId) {
pushToMarketplace(marketplaceId, { _id: id }, req.user, { isNew: false });
}
logger.debug(`Edited listing with ID: ${id}`);
res.send(result);
};
export const newListingRouteHandler = async (req, res) => {
const newData = {
updatedAt: new Date(),
product: req.body.product,
marketplace: req.body.marketplace,
title: req.body.title,
state: req.body.state || { type: 'draft' },
url: req.body.url,
};
const result = await newObject({
model: listingModel,
newData,
user: req.user,
});
if (result.error) {
logger.error('No listing created:', result.error);
return res.status(result.code).send(result);
}
try {
await newObject({
model: listingVarientModel,
newData: {
listing: result._id,
state: { type: 'draft' },
product: req.body.product || undefined,
},
user: req.user,
});
logger.debug(`Created default listing varient for listing ${result._id}`);
} catch (err) {
logger.warn(`Failed to create default listing varient: ${err.message}`);
}
const newMarketplaceId = result.marketplace?._id || result.marketplace;
if (newMarketplaceId) {
pushToMarketplace(newMarketplaceId, { _id: result._id }, req.user, { isNew: true });
}
logger.debug(`New listing with ID: ${result._id}`);
res.send(result);
};
export const deleteListingRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`Listing with ID: ${id}`);
const listing = await getObject({ model: listingModel, id });
if (listing?.error) {
logger.warn('Listing not found for deletion.');
return res.status(listing.code).send(listing);
}
const varients = await listingVarientModel.find({ listing: id });
for (const varient of varients) {
try {
await deleteObject({ model: listingVarientModel, id: varient._id, user: req.user });
} catch (err) {
logger.warn(`Failed to delete listing varient ${varient._id}: ${err.message}`);
}
}
const result = await deleteObject({
model: listingModel,
id,
user: req.user,
});
if (result.error) {
logger.error('No listing deleted:', result.error);
return res.status(result.code).send(result);
}
const delMarketplaceId = listing.marketplace?._id || listing.marketplace;
if (delMarketplaceId) {
pushToMarketplace(delMarketplaceId, listing, req.user, { isDelete: true });
}
logger.debug(`Deleted listing with ID: ${result._id}`);
res.send(result);
};
export const getListingStatsRouteHandler = async (req, res) => {
const result = await getModelStats({ model: listingModel });
if (result?.error) {
logger.error('Error fetching listing stats:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Listing stats:', result);
res.send(result);
};
export const getListingHistoryRouteHandler = async (req, res) => {
const from = req.query.from;
const to = req.query.to;
const result = await getModelHistory({ model: listingModel, from, to });
if (result?.error) {
logger.error('Error fetching listing history:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Listing history:', result);
res.send(result);
};
export const publishListingRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const stateOk = await checkStates({
model: listingModel,
id,
states: ['draft', 'inactive'],
});
if (stateOk?.error) {
logger.error('Error checking listing state:', stateOk.error);
return res.status(stateOk.code).send(stateOk);
}
if (stateOk === false) {
return res.status(400).send({
error: 'Listing must be in draft or inactive state to publish offers.',
code: 400,
});
}
const syncingCheck = await checkStates({
model: listingModel,
id,
states: ['syncing'],
});
if (syncingCheck === true) {
return res.status(400).send({
error: 'Listing is syncing; wait for sync to finish before publishing.',
code: 400,
});
}
const listing = await listingModel.findById(id).populate('marketplace').lean();
if (!listing) {
return res.status(404).send({ error: 'Listing not found.', code: 404 });
}
const marketplace = listing.marketplace;
if (!marketplace?._id) {
return res.status(400).send({
error: 'Listing has no marketplace; cannot publish offers.',
code: 400,
});
}
if (!marketplace.active) {
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
}
if (!hasIntegration(marketplace.provider)) {
return res.status(400).send({
error: 'No integration is configured for this marketplace.',
code: 400,
});
}
const varients = await listingVarientModel.find({ listing: id }).lean();
const toPublish = varients.filter((v) => v._reference && v.state?.type !== 'active');
if (toPublish.length === 0) {
return res.status(400).send({
error: 'No variants to publish (all are already active or missing SKU).',
code: 400,
});
}
try {
let firstListingId;
for (const v of toPublish) {
const apiResult = await publishMarketplaceOfferForSku(
marketplace,
req.user,
v._reference
);
await editObject({
model: listingVarientModel,
id: v._id,
updateData: {
updatedAt: new Date(),
state: { type: 'active' },
lastSyncedAt: new Date(),
},
user: req.user,
});
if (apiResult?.listingId && !firstListingId) {
firstListingId = apiResult.listingId;
}
}
const listingUpdate = {
updatedAt: new Date(),
state: { type: 'active' },
lastSyncedAt: new Date(),
};
if (firstListingId) {
listingUpdate.url = `https://www.ebay.com/itm/${firstListingId}`;
}
await editObject({
model: listingModel,
id,
updateData: listingUpdate,
user: req.user,
populate: ['product', 'marketplace'],
});
const updated = await getObject({
model: listingModel,
id,
populate: ['product', 'marketplace'],
});
res.send(updated);
} catch (err) {
logger.error(`Publish listing failed: ${err.message}`);
res.status(500).send({ error: err.message, code: 500 });
}
};
export const unpublishListingRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const syncingCheck = await checkStates({
model: listingModel,
id,
states: ['syncing'],
});
if (syncingCheck === true) {
return res.status(400).send({
error: 'Listing is syncing; wait for sync to finish before unpublishing.',
code: 400,
});
}
const listing = await listingModel.findById(id).populate('marketplace').lean();
if (!listing) {
return res.status(404).send({ error: 'Listing not found.', code: 404 });
}
const marketplace = listing.marketplace;
if (!marketplace?._id) {
return res.status(400).send({
error: 'Listing has no marketplace; cannot withdraw offers.',
code: 400,
});
}
if (!marketplace.active) {
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
}
if (!hasIntegration(marketplace.provider)) {
return res.status(400).send({
error: 'No integration is configured for this marketplace.',
code: 400,
});
}
const varients = await listingVarientModel.find({ listing: id }).lean();
const toUnpublish = varients.filter((v) => v._reference && v.state?.type === 'active');
if (toUnpublish.length === 0) {
return res.status(400).send({
error: 'No active variants to unpublish.',
code: 400,
});
}
try {
for (const v of toUnpublish) {
await withdrawMarketplaceOfferForSku(marketplace, req.user, v._reference);
await editObject({
model: listingVarientModel,
id: v._id,
updateData: {
updatedAt: new Date(),
state: { type: 'draft' },
lastSyncedAt: new Date(),
},
user: req.user,
});
}
await editObject({
model: listingModel,
id,
updateData: {
updatedAt: new Date(),
state: { type: 'draft' },
lastSyncedAt: new Date(),
},
user: req.user,
populate: ['product', 'marketplace'],
});
const updated = await getObject({
model: listingModel,
id,
populate: ['product', 'marketplace'],
});
res.send(updated);
} catch (err) {
logger.error(`Unpublish listing failed: ${err.message}`);
res.status(500).send({ error: err.message, code: 500 });
}
};

View File

@ -0,0 +1,424 @@
import config from '../../config.js';
import { listingVarientModel } from '../../database/schemas/sales/listingvarient.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import {
deleteObject,
listObjects,
getObject,
editObject,
newObject,
listObjectsByProperties,
getModelStats,
getModelHistory,
checkStates,
} from '../../database/database.js';
import { listingModel } from '../../database/schemas/sales/listing.schema.js';
import {
hasIntegration,
publishMarketplaceOfferForSku,
withdrawMarketplaceOfferForSku,
} from '../../integrations/marketplaceworker.js';
const logger = log4js.getLogger('ListingVarients');
logger.level = config.server.logLevel;
const POPULATE_FIELDS = ['listing', 'product', 'productSku', 'priceTaxRate'];
export const listListingVarientsRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: listingVarientModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: POPULATE_FIELDS,
});
if (result?.error) {
logger.error('Error listing listing varients.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of listing varients (Page ${page}, Limit ${limit}). Count: ${result.length}.`);
res.send(result);
};
export const listListingVarientsByPropertiesRouteHandler = async (
req,
res,
properties = '',
filter = {}
) => {
const result = await listObjectsByProperties({
model: listingVarientModel,
properties,
filter,
populate: POPULATE_FIELDS,
});
if (result?.error) {
logger.error('Error listing listing varients.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of listing varients. Count: ${result.length}`);
res.send(result);
};
export const getListingVarientRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: listingVarientModel,
id,
populate: POPULATE_FIELDS,
});
if (result?.error) {
logger.warn(`Listing varient not found with supplied id.`);
return res.status(result.code).send(result);
}
logger.debug(`Retrieved listing varient with ID: ${id}`);
res.send(result);
};
export const editListingVarientRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`Listing varient with ID: ${id}`);
const updateData = {
updatedAt: new Date(),
listing: req.body.listing,
product: req.body.product,
productSku: req.body.productSku,
price: req.body.price,
currency: req.body.currency,
priceTaxRate: req.body.priceTaxRate,
priceWithTax: req.body.priceWithTax,
};
const result = await editObject({
model: listingVarientModel,
id,
updateData,
user: req.user,
populate: POPULATE_FIELDS,
});
if (result.error) {
logger.error('Error editing listing varient:', result.error);
res.status(result.code).send(result);
return;
}
logger.debug(`Edited listing varient with ID: ${id}`);
res.send(result);
};
export const newListingVarientRouteHandler = async (req, res) => {
const newData = {
updatedAt: new Date(),
listing: req.body.listing,
product: req.body.product,
productSku: req.body.productSku,
state: req.body.state || { type: 'draft' },
price: req.body.price,
currency: req.body.currency,
priceTaxRate: req.body.priceTaxRate,
priceWithTax: req.body.priceWithTax,
};
const result = await newObject({
model: listingVarientModel,
newData,
user: req.user,
});
if (result.error) {
logger.error('No listing varient created:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`New listing varient with ID: ${result._id}`);
res.send(result);
};
export const deleteListingVarientRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`Listing varient with ID: ${id}`);
const result = await deleteObject({
model: listingVarientModel,
id,
user: req.user,
});
if (result.error) {
logger.error('No listing varient deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted listing varient with ID: ${result._id}`);
res.send(result);
};
export const getListingVarientStatsRouteHandler = async (req, res) => {
const result = await getModelStats({ model: listingVarientModel });
if (result?.error) {
logger.error('Error fetching listing varient stats:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Listing varient stats:', result);
res.send(result);
};
export const getListingVarientHistoryRouteHandler = async (req, res) => {
const from = req.query.from;
const to = req.query.to;
const result = await getModelHistory({ model: listingVarientModel, from, to });
if (result?.error) {
logger.error('Error fetching listing varient history:', result.error);
return res.status(result.code).send(result);
}
logger.trace('Listing varient history:', result);
res.send(result);
};
const VARIENT_POPULATE_MARKETPLACE = [
{ path: 'listing', populate: { path: 'marketplace' } },
'product',
'productSku',
'priceTaxRate',
];
export const publishListingVarientRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const stateOk = await checkStates({
model: listingVarientModel,
id,
states: ['draft', 'inactive'],
});
if (stateOk?.error) {
logger.error('Error checking listing varient state:', stateOk.error);
return res.status(stateOk.code).send(stateOk);
}
if (stateOk === false) {
return res.status(400).send({
error: 'Listing varient must be in draft or inactive state to publish.',
code: 400,
});
}
const syncingCheck = await checkStates({
model: listingVarientModel,
id,
states: ['syncing'],
});
if (syncingCheck === true) {
return res.status(400).send({
error: 'Listing varient is syncing; wait for sync to finish before publishing.',
code: 400,
});
}
const doc = await listingVarientModel
.findById(id)
.populate({ path: 'listing', populate: { path: 'marketplace' } })
.lean();
if (!doc) {
return res.status(404).send({ error: 'Listing varient not found.', code: 404 });
}
if (!doc._reference) {
return res.status(400).send({
error: 'Listing varient must have a reference (SKU) before publishing.',
code: 400,
});
}
const marketplace = doc.listing?.marketplace;
const marketplaceId = marketplace?._id || marketplace;
if (!marketplaceId) {
return res.status(400).send({
error: 'Listing has no marketplace; cannot publish offer.',
code: 400,
});
}
if (!marketplace?.active) {
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
}
if (!hasIntegration(marketplace.provider)) {
return res.status(400).send({
error: 'No integration is configured for this marketplace.',
code: 400,
});
}
try {
const apiResult = await publishMarketplaceOfferForSku(
marketplace,
req.user,
doc._reference
);
await editObject({
model: listingVarientModel,
id,
updateData: {
updatedAt: new Date(),
state: { type: 'active' },
lastSyncedAt: new Date(),
},
user: req.user,
});
const listingId = doc.listing?._id || doc.listing;
if (listingId) {
const listingUpdate = {
updatedAt: new Date(),
state: { type: 'active' },
lastSyncedAt: new Date(),
};
if (apiResult?.listingId) {
listingUpdate.url = `https://www.ebay.com/itm/${apiResult.listingId}`;
}
await editObject({
model: listingModel,
id: listingId,
updateData: listingUpdate,
user: req.user,
});
}
const updated = await getObject({
model: listingVarientModel,
id,
populate: VARIENT_POPULATE_MARKETPLACE,
});
res.send(updated);
} catch (err) {
logger.error(`Publish listing varient failed: ${err.message}`);
res.status(500).send({ error: err.message, code: 500 });
}
};
export const unpublishListingVarientRouteHandler = async (req, res) => {
const id = new mongoose.Types.ObjectId(req.params.id);
const stateOk = await checkStates({
model: listingVarientModel,
id,
states: ['active'],
});
if (stateOk?.error) {
logger.error('Error checking listing varient state:', stateOk.error);
return res.status(stateOk.code).send(stateOk);
}
if (stateOk === false) {
return res.status(400).send({
error: 'Listing varient must be in active state to unpublish.',
code: 400,
});
}
const syncingCheck = await checkStates({
model: listingVarientModel,
id,
states: ['syncing'],
});
if (syncingCheck === true) {
return res.status(400).send({
error: 'Listing varient is syncing; wait for sync to finish before unpublishing.',
code: 400,
});
}
const doc = await listingVarientModel
.findById(id)
.populate({ path: 'listing', populate: { path: 'marketplace' } })
.lean();
if (!doc) {
return res.status(404).send({ error: 'Listing varient not found.', code: 404 });
}
if (!doc._reference) {
return res.status(400).send({
error: 'Listing varient must have a reference (SKU) before unpublishing.',
code: 400,
});
}
const marketplace = doc.listing?.marketplace;
const marketplaceId = marketplace?._id || marketplace;
if (!marketplaceId) {
return res.status(400).send({
error: 'Listing has no marketplace; cannot withdraw offer.',
code: 400,
});
}
if (!marketplace?.active) {
return res.status(400).send({ error: 'Marketplace is not active.', code: 400 });
}
if (!hasIntegration(marketplace.provider)) {
return res.status(400).send({
error: 'No integration is configured for this marketplace.',
code: 400,
});
}
try {
await withdrawMarketplaceOfferForSku(marketplace, req.user, doc._reference);
await editObject({
model: listingVarientModel,
id,
updateData: {
updatedAt: new Date(),
state: { type: 'draft' },
lastSyncedAt: new Date(),
},
user: req.user,
});
const parentListingId = doc.listing?._id || doc.listing;
if (parentListingId) {
const siblings = await listingVarientModel.find({ listing: parentListingId }).lean();
const anyActive = siblings.some(
(v) => String(v._id) !== String(id) && v.state?.type === 'active'
);
if (!anyActive) {
await editObject({
model: listingModel,
id: parentListingId,
updateData: {
updatedAt: new Date(),
state: { type: 'draft' },
lastSyncedAt: new Date(),
},
user: req.user,
});
}
}
const updated = await getObject({
model: listingVarientModel,
id,
populate: VARIENT_POPULATE_MARKETPLACE,
});
res.send(updated);
} catch (err) {
logger.error(`Unpublish listing varient failed: ${err.message}`);
res.status(500).send({ error: err.message, code: 500 });
}
};

View File

@ -12,6 +12,7 @@ import {
getModelStats,
getModelHistory,
} from '../../database/database.js';
import * as marketplaceIntegration from '../../integrations/marketplaceworker.js';
const logger = log4js.getLogger('Marketplaces');
logger.level = config.server.logLevel;
@ -118,6 +119,8 @@ export const newMarketplaceRouteHandler = async (req, res) => {
name: req.body.name,
provider: req.body.provider,
active: req.body.active !== false,
connected: req.body.connected === true,
state: req.body.state || { type: req.body.active ? 'disconnected' : 'inactive' },
config: req.body.config || {},
};
const result = await newObject({
@ -174,3 +177,189 @@ export const getMarketplaceHistoryRouteHandler = async (req, res) => {
logger.trace('Marketplace history:', result);
res.send(result);
};
export const getMarketplaceAuthUrlRouteHandler = async (req, res) => {
const id = req.params.id;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
logger.warn('Marketplace not found for authorization URL request.');
return res.status(marketplace.code).send(marketplace);
}
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
return res.status(400).send({
error: `No integration available for provider: ${marketplace.provider}`,
code: 400,
});
}
if (!marketplaceIntegration.canAuthorize(marketplace)) {
return res.status(400).send({
error: `Provider ${marketplace.provider} does not support marketplace authorization.`,
code: 400,
});
}
try {
const url = marketplaceIntegration.getAuthorizationUrl(marketplace, {
state: req.query.state,
});
res.send({ success: true, url });
} catch (err) {
logger.error('Error generating marketplace authorization URL:', err.message);
res.status(400).send({ error: err.message, code: 400 });
}
};
export const exchangeMarketplaceAuthCodeRouteHandler = async (req, res) => {
const id = req.params.id;
const { code, state } = req.body;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
logger.warn('Marketplace not found for authorization exchange.');
return res.status(marketplace.code).send(marketplace);
}
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.exchangeAuthorizationCode(marketplace, req.user, {
code,
state,
});
logger.info(`Marketplace authorization completed for ${marketplace.name}`);
res.send({ success: true, ...result });
} catch (err) {
logger.error('Error exchanging marketplace authorization code:', err.message);
res.status(400).send({ error: err.message, code: 400 });
}
};
export const refreshMarketplaceAuthRouteHandler = async (req, res) => {
const id = req.params.id;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
logger.warn('Marketplace not found for token refresh.');
return res.status(marketplace.code).send(marketplace);
}
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.refreshMarketplaceAuth(marketplace, req.user);
logger.info(`Marketplace token refreshed for ${marketplace.name}`);
res.send({ success: true, ...result });
} catch (err) {
logger.error('Error refreshing marketplace token:', err.message);
res.status(400).send({ error: err.message, code: 400 });
}
};
export const syncMarketplaceItemsRouteHandler = async (req, res) => {
const id = req.params.id;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
logger.warn('Marketplace not found for 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,
});
}
marketplaceIntegration.syncItems(marketplace, req.user);
logger.info(`Item sync initiated in background for marketplace ${marketplace.name}`);
res.send({ success: true, message: 'Item sync started' });
};
export const syncMarketplaceOrdersRouteHandler = async (req, res) => {
const id = req.params.id;
const { startTime, endTime } = req.query;
const marketplace = await getObject({ model: marketplaceModel, id });
if (marketplace?.error) {
logger.warn('Marketplace not found for order 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,
});
}
marketplaceIntegration.syncOrders(marketplace, req.user, {
startTime: startTime ? parseInt(startTime) : undefined,
endTime: endTime ? parseInt(endTime) : undefined,
});
logger.info(`Order sync initiated in background for marketplace ${marketplace.name}`);
res.send({ success: true, message: 'Order sync started' });
};
export const marketplaceWebhookRouteHandler = 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.');
return res.status(404).send({ error: 'Marketplace not found.', code: 404 });
}
if (!marketplaceIntegration.hasIntegration(marketplace.provider)) {
return res.status(400).send({
error: `No integration available for provider: ${marketplace.provider}`,
code: 400,
});
}
const signature =
req.headers['x-tts-signature'] ||
req.headers['x-ebay-signature'] ||
req.headers['x-signature'] ||
'';
const rawBody = JSON.stringify(req.body);
if (signature && marketplaceIntegration.canVerifyWebhookSignature(marketplace)) {
const valid = marketplaceIntegration.verifyWebhookSignature(marketplace, rawBody, signature);
if (!valid) {
logger.warn(`Invalid webhook signature for marketplace ${marketplace.name}`);
return res.status(401).send({ error: 'Invalid signature.', code: 401 });
}
}
try {
const result = await marketplaceIntegration.handleWebhook(marketplace, req.body);
logger.info(`Webhook processed for marketplace ${marketplace.name}: ${result.action}`);
res.send({ success: true, ...result });
} catch (err) {
logger.error('Error processing marketplace webhook:', err.message);
res.status(500).send({ error: err.message, code: 500 });
}
};

View File

@ -30,6 +30,11 @@ function buildWildcardRegexPattern(input) {
}
function parseFilter(property, value) {
// Normalize state filter to state.type for schemas with state: { type }
if (property === 'state') {
property = 'state.type';
}
if (value?._id !== undefined && value?._id !== null) {
return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } };
}