diff --git a/src/database/schemas/management/product.schema.js b/src/database/schemas/management/product.schema.js index ec10453..fcc9392 100644 --- a/src/database/schemas/management/product.schema.js +++ b/src/database/schemas/management/product.schema.js @@ -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 diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 0829ea4..86c5a26 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -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', + }, }; diff --git a/src/database/schemas/sales/listing.schema.js b/src/database/schemas/sales/listing.schema.js new file mode 100644 index 0000000..cb824e8 --- /dev/null +++ b/src/database/schemas/sales/listing.schema.js @@ -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); diff --git a/src/database/schemas/sales/listingvarient.schema.js b/src/database/schemas/sales/listingvarient.schema.js new file mode 100644 index 0000000..12cc007 --- /dev/null +++ b/src/database/schemas/sales/listingvarient.schema.js @@ -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); diff --git a/src/database/schemas/sales/marketplace.schema.js b/src/database/schemas/sales/marketplace.schema.js index 17c9ce5..06caf37 100644 --- a/src/database/schemas/sales/marketplace.schema.js +++ b/src/database/schemas/sales/marketplace.schema.js @@ -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); diff --git a/src/index.js b/src/index.js index f6f0c96..b4d2b04 100644 --- a/src/index.js +++ b/src/index.js @@ -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); diff --git a/src/integrations/marketplaces/ebay/auth.js b/src/integrations/marketplaces/ebay/auth.js new file mode 100644 index 0000000..d5a7f15 --- /dev/null +++ b/src/integrations/marketplaces/ebay/auth.js @@ -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; + } +} diff --git a/src/integrations/marketplaces/ebay/index.js b/src/integrations/marketplaces/ebay/index.js new file mode 100644 index 0000000..8608c49 --- /dev/null +++ b/src/integrations/marketplaces/ebay/index.js @@ -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'; diff --git a/src/integrations/marketplaces/ebay/listings.js b/src/integrations/marketplaces/ebay/listings.js new file mode 100644 index 0000000..c4a8c00 --- /dev/null +++ b/src/integrations/marketplaces/ebay/listings.js @@ -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, + }; +} diff --git a/src/integrations/marketplaces/ebay/orders.js b/src/integrations/marketplaces/ebay/orders.js new file mode 100644 index 0000000..87fee25 --- /dev/null +++ b/src/integrations/marketplaces/ebay/orders.js @@ -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 }; + } +} diff --git a/src/integrations/marketplaces/ebay/shared.js b/src/integrations/marketplaces/ebay/shared.js new file mode 100644 index 0000000..375181a --- /dev/null +++ b/src/integrations/marketplaces/ebay/shared.js @@ -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 }; diff --git a/src/integrations/marketplaces/tiktokShop.js b/src/integrations/marketplaces/tiktokShop.js new file mode 100644 index 0000000..72ef277 --- /dev/null +++ b/src/integrations/marketplaces/tiktokShop.js @@ -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; + } +} diff --git a/src/integrations/marketplaceworker.js b/src/integrations/marketplaceworker.js new file mode 100644 index 0000000..a243dc5 --- /dev/null +++ b/src/integrations/marketplaceworker.js @@ -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(); +} diff --git a/src/routes/index.js b/src/routes/index.js index c193483..e95b69c 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -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, diff --git a/src/routes/sales/listings.js b/src/routes/sales/listings.js new file mode 100644 index 0000000..70f3eb4 --- /dev/null +++ b/src/routes/sales/listings.js @@ -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; diff --git a/src/routes/sales/listingvarients.js b/src/routes/sales/listingvarients.js new file mode 100644 index 0000000..1b2f71e --- /dev/null +++ b/src/routes/sales/listingvarients.js @@ -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; diff --git a/src/routes/sales/marketplaces.js b/src/routes/sales/marketplaces.js index 0faec3d..854605a 100644 --- a/src/routes/sales/marketplaces.js +++ b/src/routes/sales/marketplaces.js @@ -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); }); diff --git a/src/services/sales/listings.js b/src/services/sales/listings.js new file mode 100644 index 0000000..e5032fd --- /dev/null +++ b/src/services/sales/listings.js @@ -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 }); + } +}; diff --git a/src/services/sales/listingvarients.js b/src/services/sales/listingvarients.js new file mode 100644 index 0000000..5ab7755 --- /dev/null +++ b/src/services/sales/listingvarients.js @@ -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 }); + } +}; diff --git a/src/services/sales/marketplaces.js b/src/services/sales/marketplaces.js index 56bb6a2..dcc8b09 100644 --- a/src/services/sales/marketplaces.js +++ b/src/services/sales/marketplaces.js @@ -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 }); + } +}; diff --git a/src/utils.js b/src/utils.js index e7e1a55..d2f9875 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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) } }; }