Tom Butcher 61d29a8f7e Add syncHash and syncImageHash fields to listing and variant schemas; implement sync fingerprinting for marketplace integration
This commit introduces new fields `syncHash` and `syncImageHash` to the `listing` and `listingVariant` schemas, enhancing the tracking of synchronization states. Additionally, a new module for sync fingerprinting is added, which includes functions to generate hashes for listings and their variants. The marketplace integration logic is updated to utilize these hashes for determining if updates are necessary, improving efficiency in syncing operations. Tests are also added to ensure the correctness of the new functionality and its integration with existing systems.
2026-08-29 22:34:11 +01:00

298 lines
8.7 KiB
JavaScript

import { makeRequest, logger, getEbayMarketplaceId } from './shared.js';
import { hashSyncPayload } from '../syncFingerprint.js';
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
export const POLICY_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES';
export function idOf(value) {
if (value == null) return '';
if (typeof value === 'object') {
if (value._id != null) return String(value._id);
return String(value);
}
return String(value);
}
export function isPopulatedDocument(value) {
return (
value &&
typeof value === 'object' &&
!(value.constructor?.name === 'ObjectId') &&
(value.name != null || value._reference != null || Array.isArray(value.marketplaces))
);
}
export function getMarketplaceMapping(doc, marketplace) {
const marketplaceId = idOf(marketplace);
if (!marketplaceId) return (doc?.marketplaces || [])[0] || null;
return (
(doc?.marketplaces || []).find((entry) => idOf(entry.marketplace) === marketplaceId) || null
);
}
export function mappingExternalReference(doc, marketplace) {
return getMarketplaceMapping(doc, marketplace)?.externalReference || '';
}
export function mappingSyncHash(doc, marketplace) {
return getMarketplaceMapping(doc, marketplace)?.syncHash || '';
}
export function shouldSkipMappedPolicySync(doc, marketplace, fingerprint) {
const mapping = getMarketplaceMapping(doc, marketplace);
if (!mapping?.externalReference || !mapping?.syncHash || fingerprint == null) {
return false;
}
return mapping.syncHash === hashSyncPayload(fingerprint);
}
export async function persistMarketplaceMapping(
model,
doc,
marketplace,
{ externalReference, stateType, message, syncHash } = {}
) {
if (!model || !doc) return null;
const marketplaceId = idOf(marketplace);
const docId = idOf(doc);
if (!marketplaceId || !docId) return null;
const current = (await model.findById(docId).lean()) || doc;
const mappings = [...(current?.marketplaces || [])];
const index = mappings.findIndex((entry) => idOf(entry.marketplace) === marketplaceId);
const previous = index >= 0 ? mappings[index] : {};
const next = {
...(previous._id ? { _id: previous._id } : {}),
marketplace: previous.marketplace || marketplaceId,
externalReference:
externalReference !== undefined ? externalReference : previous.externalReference,
state: {
type: stateType || previous.state?.type || 'pending',
...(message != null && message !== ''
? { message }
: stateType === 'failed'
? { message: previous.state?.message }
: {}),
},
};
if (syncHash !== undefined) {
next.syncHash = syncHash;
} else if (previous.syncHash) {
next.syncHash = previous.syncHash;
}
if (index >= 0) mappings[index] = next;
else mappings.push(next);
await model.updateOne({ _id: docId }, { $set: { marketplaces: mappings } });
try {
const { deleteObjectCache } = await import('../../../database/database.js');
const { distributeUpdate } = await import('../../../utils.js');
await deleteObjectCache({ model, id: docId });
let broadcastMappings = mappings;
const query = model.findById(docId);
if (typeof query?.populate === 'function') {
const populated = await query.populate('marketplaces.marketplace').lean();
if (populated?.marketplaces) broadcastMappings = populated.marketplaces;
}
await distributeUpdate({ marketplaces: broadcastMappings }, docId, model.modelName);
} catch (err) {
logger.debug(`Could not broadcast marketplace mapping update for ${docId}: ${err.message}`);
}
return next;
}
async function fetchOptedInPrograms(marketplace) {
const result = await makeRequest({
marketplace,
path: '/sell/account/v1/program/get_opted_in_programs',
});
return result?.programs || [];
}
function hasSellingPolicyManagement(programs) {
return programs.some((program) => program?.programType === SELLING_POLICY_PROGRAM);
}
export async function ensureSellingPolicyManagement(marketplace) {
if (hasSellingPolicyManagement(await fetchOptedInPrograms(marketplace))) {
return;
}
logger.info('Opting eBay seller account into Selling Policy Management');
await makeRequest({
marketplace,
method: 'POST',
path: '/sell/account/v1/program/opt_in',
body: { programType: SELLING_POLICY_PROGRAM },
acceptableStatuses: [409],
});
if (!hasSellingPolicyManagement(await fetchOptedInPrograms(marketplace))) {
throw new Error(
'eBay Selling Policy Management enrollment was requested but is not active yet. eBay can take up to 24 hours to process enrollment; retry publishing later.'
);
}
}
export function isDefaultAccountPolicy(existingPolicy, allPolicies = [], idField) {
const existingId = existingPolicy?.[idField];
if (!existingId) return false;
const existingIdString = String(existingId);
const sources = [existingPolicy, ...allPolicies];
if (
sources.some(
(policy) =>
String(policy?.[idField]) === existingIdString &&
(policy.categoryTypes || []).some((type) => type?.default === true)
)
) {
return true;
}
const uniqueIds = [
...new Set(
allPolicies.filter((policy) => policy?.[idField]).map((policy) => String(policy[idField]))
),
];
return uniqueIds.length === 1 && uniqueIds[0] === existingIdString;
}
export function buildCategoryTypes(existingPolicy, allPolicies = [], idField) {
const categoryType = { name: POLICY_CATEGORY_TYPE };
if (idField && isDefaultAccountPolicy(existingPolicy, allPolicies, idField)) {
categoryType.default = true;
}
return [categoryType];
}
function isDefaultStatusError(err) {
return /changing the default status/i.test(err?.message || '');
}
function isUnchangedPolicyError(err) {
return /same as in the system/i.test(err?.message || '');
}
export async function updateAccountPolicy(marketplace, path, policy) {
try {
await makeRequest({ marketplace, method: 'PUT', path, body: policy });
} catch (err) {
if (isUnchangedPolicyError(err)) {
logger.debug(`eBay account policy ${path} is already up to date`);
return;
}
if (policy.categoryTypes?.[0]?.default === true || !isDefaultStatusError(err)) {
throw err;
}
logger.debug(`Retrying account policy ${path} with categoryTypes.default=true`);
try {
await makeRequest({
marketplace,
method: 'PUT',
path,
body: {
...policy,
categoryTypes: [{ name: POLICY_CATEGORY_TYPE, default: true }],
},
});
} catch (retryErr) {
if (isUnchangedPolicyError(retryErr)) {
logger.debug(`eBay account policy ${path} is already up to date`);
return;
}
throw retryErr;
}
}
}
export async function loadPolicyDocument(model, value) {
if (!value) return null;
if (isPopulatedDocument(value)) return value;
const id = idOf(value);
if (!id) return null;
return model.findById(id).populate(['marketplaces.marketplace']).lean();
}
export function resolveListingPolicy(listing, marketplace, listingField, defaultField) {
return (
listing?.[listingField] ||
listing?.marketplace?.[defaultField] ||
marketplace?.[defaultField] ||
null
);
}
export async function findLocalPolicyByExternalReference(model, marketplace, externalReference) {
if (!externalReference) return null;
return model
.findOne({
marketplaces: {
$elemMatch: {
marketplace: idOf(marketplace),
externalReference: String(externalReference),
},
},
})
.lean();
}
export async function findLocalPolicyByName(model, marketplace, name) {
if (!name) return null;
return model
.findOne({
name,
'marketplaces.marketplace': idOf(marketplace),
})
.lean();
}
export async function upsertLocalPolicyFromRemote({
model,
marketplace,
remoteId,
name,
fields,
user,
}) {
let local =
(await findLocalPolicyByExternalReference(model, marketplace, remoteId)) ||
(await findLocalPolicyByName(model, marketplace, name));
const mapping = {
marketplace: idOf(marketplace),
externalReference: String(remoteId),
state: { type: 'ready' },
};
if (local) {
const mappings = [...(local.marketplaces || [])];
const index = mappings.findIndex((entry) => idOf(entry.marketplace) === idOf(marketplace));
if (index >= 0) mappings[index] = { ...mappings[index], ...mapping };
else mappings.push(mapping);
await model.updateOne(
{ _id: local._id },
{
$set: {
...fields,
name,
marketplaces: mappings,
},
}
);
return model.findById(local._id).lean();
}
const created = await model.create({
...fields,
name,
marketplaces: [mapping],
});
return created.toObject ? created.toObject() : created;
}
export { getEbayMarketplaceId };