Add audit owner resolution and actor display name functions
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
This commit introduces a new module, `auditOwner.js`, which includes functions for resolving audit owner details and generating display names for actors. The `resolveAuditOwner` function determines the owner type based on the actor's object type, defaulting to 'user' if not specified. The `actorDisplayName` function formats the display name based on the actor's properties, enhancing the clarity of audit logs. Additionally, the `AUDIT_OWNER_TYPES` constant is exported for use in other modules. Updates to existing files incorporate these new functions for improved audit logging and notification handling.
This commit is contained in:
parent
8974d1e9da
commit
5ff93be27c
22
src/auditOwner.js
Normal file
22
src/auditOwner.js
Normal file
@ -0,0 +1,22 @@
|
||||
const AUDIT_OWNER_TYPES = ['user', 'printer', 'host', 'marketplace'];
|
||||
|
||||
export function resolveAuditOwner(actor) {
|
||||
const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType)
|
||||
? actor._objectType
|
||||
: 'user';
|
||||
return {
|
||||
owner: actor?._id,
|
||||
ownerType,
|
||||
};
|
||||
}
|
||||
|
||||
export function actorDisplayName(actor) {
|
||||
if (actor?._objectType === 'marketplace' || actor?._objectType === 'host') {
|
||||
return actor.name || actor._objectType;
|
||||
}
|
||||
const firstName = actor?.firstName ?? 'unknown';
|
||||
const lastName = actor?.lastName ?? '';
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
|
||||
export { AUDIT_OWNER_TYPES };
|
||||
103
src/database/schemas/__tests__/stockQuantity.recalculate.test.js
Normal file
103
src/database/schemas/__tests__/stockQuantity.recalculate.test.js
Normal file
@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
jest.unstable_mockModule('../../database.js', () => ({
|
||||
aggregateRollups: jest.fn(),
|
||||
aggregateRollupsHistory: jest.fn(),
|
||||
editObject: jest.fn(),
|
||||
getObject: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../utils.js', () => ({
|
||||
generateId: jest.fn(() => () => 'test-id'),
|
||||
}));
|
||||
|
||||
const { aggregateRollups, editObject } = await import('../../database.js');
|
||||
const { listingModel } = await import('../sales/listing.schema.js');
|
||||
const { listingVarientModel } = await import('../sales/listingvarient.schema.js');
|
||||
const { productSkuModel } = await import('../management/productsku.schema.js');
|
||||
const { productStockModel } = await import('../inventory/productstock.schema.js');
|
||||
|
||||
const listingId = new mongoose.Types.ObjectId();
|
||||
const productId = new mongoose.Types.ObjectId();
|
||||
const productSkuId = new mongoose.Types.ObjectId();
|
||||
const stockLocationId = new mongoose.Types.ObjectId();
|
||||
const varientId = new mongoose.Types.ObjectId();
|
||||
|
||||
describe('listingVarient.recalculate', () => {
|
||||
beforeEach(() => {
|
||||
aggregateRollups.mockReset();
|
||||
editObject.mockReset();
|
||||
});
|
||||
|
||||
it('sums sibling listing varient stock quantities onto the listing', async () => {
|
||||
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 12 } });
|
||||
editObject.mockResolvedValue({});
|
||||
|
||||
await listingVarientModel.recalculate({ listing: listingId, stockQuantity: 4 }, 'user-1');
|
||||
|
||||
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: listingVarientModel,
|
||||
baseFilter: { listing: listingId },
|
||||
})
|
||||
);
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingModel,
|
||||
id: listingId,
|
||||
updateData: { stockQuantity: 12 },
|
||||
user: 'user-1',
|
||||
recalculate: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('productStock.recalculate', () => {
|
||||
beforeEach(() => {
|
||||
aggregateRollups.mockReset();
|
||||
editObject.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('writes the sku/location total onto matching listing varients', async () => {
|
||||
aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 9 } });
|
||||
editObject.mockResolvedValue({});
|
||||
jest.spyOn(productSkuModel, 'findById').mockReturnValue({
|
||||
select: () => ({ lean: async () => ({ product: productId }) }),
|
||||
});
|
||||
jest.spyOn(listingVarientModel, 'find').mockReturnValue({
|
||||
populate: () => ({
|
||||
lean: async () => [
|
||||
{
|
||||
_id: varientId,
|
||||
product: productId,
|
||||
productSku: productSkuId,
|
||||
stockQuantity: 0,
|
||||
listing: { product: productId, stockLocation: stockLocationId },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await productStockModel.recalculate(
|
||||
{ productSku: productSkuId, stockLocation: stockLocationId, currentQuantity: 9 },
|
||||
'user-1'
|
||||
);
|
||||
|
||||
expect(aggregateRollups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: productStockModel,
|
||||
baseFilter: {
|
||||
productSku: productSkuId,
|
||||
stockLocation: stockLocationId,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(editObject).toHaveBeenCalledWith({
|
||||
model: listingVarientModel,
|
||||
id: varientId,
|
||||
updateData: { stockQuantity: 9 },
|
||||
user: 'user-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -63,6 +63,9 @@ const orderItemSchema = new Schema(
|
||||
invoicedQuantityRemaining: { type: Number, required: false, default: 0 },
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
shipment: { type: Schema.Types.ObjectId, ref: 'shipment', required: false },
|
||||
listing: { type: Schema.Types.ObjectId, ref: 'listing', required: false },
|
||||
listingVarient: { type: Schema.Types.ObjectId, ref: 'listingVarient', required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
orderedAt: { type: Date, required: false },
|
||||
receivedAt: { type: Date, required: false },
|
||||
},
|
||||
@ -70,6 +73,7 @@ const orderItemSchema = new Schema(
|
||||
);
|
||||
|
||||
orderItemSchema.index({ name: 'text', itemType: 'text', orderType: 'text' });
|
||||
orderItemSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
@ -132,7 +136,13 @@ orderItemSchema.statics.recalculate = async function (orderItem, user) {
|
||||
cached: true,
|
||||
});
|
||||
if (sku) {
|
||||
const parentId = sku.part?._id || sku.part || sku.product?._id || sku.product || sku.filament?._id || sku.filament;
|
||||
const parentId =
|
||||
sku.part?._id ||
|
||||
sku.part ||
|
||||
sku.product?._id ||
|
||||
sku.product ||
|
||||
sku.filament?._id ||
|
||||
sku.filament;
|
||||
if (syncAmount === 'itemCost') {
|
||||
if (sku.overrideCost && sku.cost != null) {
|
||||
effectiveItemAmount = sku.cost;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
|
||||
const partStockUsageSchema = new Schema({
|
||||
partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false },
|
||||
@ -9,6 +9,12 @@ const partStockUsageSchema = new Schema({
|
||||
quantity: { type: Number, required: true },
|
||||
});
|
||||
|
||||
const toId = (value) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object' && value._id) return String(value._id);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Define the main productStock schema - tracks assembled products consisting of part stocks
|
||||
const productStockSchema = new Schema(
|
||||
{
|
||||
@ -76,6 +82,64 @@ productStockSchema.statics.history = async function (from, to) {
|
||||
return results;
|
||||
};
|
||||
|
||||
productStockSchema.statics.recalculate = async function (productStock, user) {
|
||||
const productSkuId = toId(productStock?.productSku);
|
||||
const stockLocationId = toId(productStock?.stockLocation);
|
||||
if (!productSkuId || !stockLocationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let productId = toId(productStock?.productSku?.product);
|
||||
if (!productId) {
|
||||
const productSku = await mongoose.model('productSku').findById(productSkuId).select('product').lean();
|
||||
productId = toId(productSku?.product);
|
||||
}
|
||||
if (!productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupResults = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: {
|
||||
productSku: new mongoose.Types.ObjectId(productSkuId),
|
||||
stockLocation: new mongoose.Types.ObjectId(stockLocationId),
|
||||
},
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'stockQuantity',
|
||||
rollups: [{ name: 'stockQuantity', property: 'currentQuantity', operation: 'sum' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const stockQuantity = rollupResults.stockQuantity?.sum || 0;
|
||||
|
||||
const listingVarientModel = mongoose.model('listingVarient');
|
||||
const varients = await listingVarientModel
|
||||
.find({ productSku: productSkuId })
|
||||
.populate('listing')
|
||||
.lean();
|
||||
|
||||
for (const varient of varients) {
|
||||
const varientProductId =
|
||||
toId(varient.product) || toId(varient.listing?.product);
|
||||
const varientLocationId = toId(varient.listing?.stockLocation);
|
||||
if (varientProductId !== productId || varientLocationId !== stockLocationId) {
|
||||
continue;
|
||||
}
|
||||
if (varient.stockQuantity === stockQuantity) {
|
||||
await listingVarientModel.recalculate(varient, user);
|
||||
continue;
|
||||
}
|
||||
|
||||
await editObject({
|
||||
model: listingVarientModel,
|
||||
id: varient._id,
|
||||
updateData: { stockQuantity },
|
||||
user,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add virtual id getter
|
||||
productStockSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -13,6 +13,7 @@ const shipmentSchema = new Schema(
|
||||
order: { type: Schema.Types.ObjectId, refPath: 'orderType', required: true },
|
||||
courierService: { type: Schema.Types.ObjectId, ref: 'courierService', required: false },
|
||||
trackingNumber: { type: String, required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
amount: { type: Number, required: true },
|
||||
amountWithTax: { type: Number, required: true },
|
||||
taxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false },
|
||||
@ -35,6 +36,7 @@ const shipmentSchema = new Schema(
|
||||
);
|
||||
|
||||
shipmentSchema.index({ trackingNumber: 'text', orderType: 'text' });
|
||||
shipmentSchema.index({ order: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
shipmentSchema.statics.recalculate = async function (shipment, user) {
|
||||
if (shipment.orderType !== 'purchaseOrder' && shipment.orderType !== 'salesOrder') {
|
||||
|
||||
@ -190,7 +190,7 @@ const recalculateParentStock = async (parentType, parentId, user) => {
|
||||
buildParentUpdateData(parentType, parentStock, events)
|
||||
),
|
||||
user,
|
||||
recalculate: false,
|
||||
recalculate: parentType === 'productStock',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ const auditLogSchema = new Schema(
|
||||
ownerType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['user', 'printer', 'host'],
|
||||
enum: ['user', 'printer', 'host', 'marketplace'],
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
|
||||
@ -14,7 +14,14 @@ const courierSchema = new mongoose.Schema(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
courierSchema.index({ name: 'text', website: 'text', email: 'text', phone: 'text', contact: 'text', country: 'text' });
|
||||
courierSchema.index({
|
||||
name: 'text',
|
||||
website: 'text',
|
||||
email: 'text',
|
||||
phone: 'text',
|
||||
contact: 'text',
|
||||
country: 'text',
|
||||
});
|
||||
|
||||
courierSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -2,6 +2,14 @@ import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const marketplaceMappingSchema = new mongoose.Schema(
|
||||
{
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: false },
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const courierServiceSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
@ -18,6 +26,7 @@ const courierServiceSchema = new mongoose.Schema(
|
||||
additionalCostWithTax: { required: false, type: Number },
|
||||
shippingCurrency: { required: true, type: String, default: 'GBP' },
|
||||
international: { required: true, type: Boolean, default: false },
|
||||
marketplaces: { type: [marketplaceMappingSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@ -48,6 +48,7 @@ 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';
|
||||
import { marketplaceEventModel } from './sales/marketplaceevent.schema.js';
|
||||
import { paymentModel } from './finance/payment.schema.js';
|
||||
|
||||
// Map prefixes to models and id fields
|
||||
@ -396,6 +397,13 @@ export const models = {
|
||||
label: 'Listing Varient',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
MKE: {
|
||||
model: marketplaceEventModel,
|
||||
idField: '_id',
|
||||
type: 'marketplaceEvent',
|
||||
label: 'Marketplace Event',
|
||||
referenceField: '_reference',
|
||||
},
|
||||
PAY: {
|
||||
model: paymentModel,
|
||||
idField: '_id',
|
||||
|
||||
@ -16,6 +16,7 @@ const clientSchema = new mongoose.Schema(
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { required: true, type: String },
|
||||
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
email: { required: false, type: String },
|
||||
phone: { required: false, type: String },
|
||||
country: { required: false, type: String },
|
||||
@ -27,6 +28,7 @@ const clientSchema = new mongoose.Schema(
|
||||
);
|
||||
|
||||
clientSchema.index({ name: 'text', email: 'text', phone: 'text', country: 'text', tags: 'text' });
|
||||
clientSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
clientSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -19,15 +19,43 @@ const listingSchema = new Schema(
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
url: { type: String, required: false },
|
||||
description: { type: String, required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
price: { type: Number, required: false },
|
||||
currency: { type: String, required: false },
|
||||
lastSyncedAt: { type: Date, required: false },
|
||||
stockQuantity: { type: Number, required: false, default: 0 },
|
||||
condition: {
|
||||
type: String,
|
||||
enum: [
|
||||
'new',
|
||||
'likeNew',
|
||||
'newOther',
|
||||
'newWithDefects',
|
||||
'manufacturerRefurbished',
|
||||
'certifiedRefurbished',
|
||||
'excellentRefurbished',
|
||||
'veryGoodRefurbished',
|
||||
'goodRefurbished',
|
||||
'sellerRefurbished',
|
||||
'usedExcellent',
|
||||
'usedVeryGood',
|
||||
'usedGood',
|
||||
'usedAcceptable',
|
||||
'forPartsOrNotWorking',
|
||||
'preOwnedExcellent',
|
||||
'preOwnedFair',
|
||||
],
|
||||
default: 'new',
|
||||
required: false,
|
||||
},
|
||||
courierServices: [{ type: Schema.Types.ObjectId, ref: 'courierService', required: true }],
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
listingSchema.index({ title: 'text', url: 'text' });
|
||||
listingSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
listingSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
import { aggregateRollups, editObject } from '../../database.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const listingVarientSchema = new Schema(
|
||||
@ -16,16 +17,19 @@ const listingVarientSchema = new Schema(
|
||||
},
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
externalReference: { 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 },
|
||||
stockQuantity: { type: Number, required: false, default: 0 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
listingVarientSchema.index({ currency: 'text', 'state.type': 'text' });
|
||||
listingVarientSchema.index({ listing: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
listingVarientSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
@ -42,4 +46,32 @@ listingVarientSchema.set('toJSON', {
|
||||
},
|
||||
});
|
||||
|
||||
listingVarientSchema.statics.recalculate = async function (listingVarient, user) {
|
||||
const listingId = listingVarient?.listing?._id || listingVarient?.listing;
|
||||
if (!listingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupResults = await aggregateRollups({
|
||||
model: this,
|
||||
baseFilter: { listing: new mongoose.Types.ObjectId(listingId) },
|
||||
rollupConfigs: [
|
||||
{
|
||||
name: 'stockQuantity',
|
||||
rollups: [{ name: 'stockQuantity', property: 'stockQuantity', operation: 'sum' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await editObject({
|
||||
model: mongoose.model('listing'),
|
||||
id: listingId,
|
||||
updateData: {
|
||||
stockQuantity: rollupResults.stockQuantity?.sum || 0,
|
||||
},
|
||||
user,
|
||||
recalculate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema);
|
||||
|
||||
@ -17,13 +17,16 @@ const marketplaceSchema = new mongoose.Schema(
|
||||
state: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing'],
|
||||
enum: ['active', 'inactive', 'suspended', 'ready', 'offline', 'syncing', 'disconnected'],
|
||||
default: 'offline',
|
||||
},
|
||||
message: { type: String, required: false },
|
||||
},
|
||||
// Provider-specific API configuration (flexible for eBay, Etsy, TikTok Shop)
|
||||
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
eBay: {
|
||||
availableShippingServices: { type: [String], default: [] },
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
31
src/database/schemas/sales/marketplaceevent.schema.js
Normal file
31
src/database/schemas/sales/marketplaceevent.schema.js
Normal file
@ -0,0 +1,31 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
|
||||
const marketplaceEventSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
marketplace: { type: mongoose.Schema.Types.ObjectId, ref: 'marketplace', required: true },
|
||||
externalReference: { type: String, required: true },
|
||||
topic: { type: String, required: true },
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['received', 'processed', 'failed'],
|
||||
default: 'received',
|
||||
},
|
||||
processedAt: { type: Date, required: false },
|
||||
error: { type: String, required: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
marketplaceEventSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true });
|
||||
marketplaceEventSchema.index({ topic: 'text', status: 'text' });
|
||||
|
||||
marketplaceEventSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
marketplaceEventSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const marketplaceEventModel = mongoose.model('marketplaceEvent', marketplaceEventSchema);
|
||||
@ -1,11 +1,7 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
import {
|
||||
aggregateRollups,
|
||||
aggregateRollupsHistory,
|
||||
editObject,
|
||||
} from '../../database.js';
|
||||
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
|
||||
|
||||
const salesOrderSchema = new Schema(
|
||||
{
|
||||
@ -19,6 +15,7 @@ const salesOrderSchema = new Schema(
|
||||
timestamp: { type: Date, default: Date.now },
|
||||
client: { type: Schema.Types.ObjectId, ref: 'client', required: true },
|
||||
marketplace: { type: Schema.Types.ObjectId, ref: 'marketplace', required: false },
|
||||
externalReference: { type: String, required: false },
|
||||
state: {
|
||||
type: { type: String, required: true, default: 'draft' },
|
||||
},
|
||||
@ -31,6 +28,7 @@ const salesOrderSchema = new Schema(
|
||||
);
|
||||
|
||||
salesOrderSchema.index({ 'state.type': 'text' });
|
||||
salesOrderSchema.index({ marketplace: 1, externalReference: 1 }, { unique: true, sparse: true });
|
||||
|
||||
const rollupConfigs = [
|
||||
{
|
||||
|
||||
@ -2,10 +2,11 @@ import { customAlphabet } from 'nanoid';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
export const generateId = () => {
|
||||
const createId = () => {
|
||||
// 10 characters
|
||||
return customAlphabet(ALPHABET, 12);
|
||||
};
|
||||
export const generateId = createId;
|
||||
|
||||
/** Check if a value is a string that looks like a MongoDB ObjectId (24 hex chars). */
|
||||
export function isObjectIdString(value) {
|
||||
|
||||
11
src/index.js
11
src/index.js
@ -145,7 +145,16 @@ async function initializeApp() {
|
||||
|
||||
// Configure middleware
|
||||
app.use(cors(corsOptions));
|
||||
app.use(bodyParser.json({ type: 'application/json', strict: false, limit: '50mb' }));
|
||||
app.use(
|
||||
bodyParser.json({
|
||||
type: 'application/json',
|
||||
strict: false,
|
||||
limit: '50mb',
|
||||
verify: (req, res, buf) => {
|
||||
req.rawBody = buf.toString('utf8');
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(populateUserMiddleware);
|
||||
|
||||
|
||||
142
src/integrations/__tests__/marketplaceSync.test.js
Normal file
142
src/integrations/__tests__/marketplaceSync.test.js
Normal file
@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
jest.unstable_mockModule('../../database/database.js', () => ({
|
||||
newObject: jest.fn(),
|
||||
editObject: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/sales/client.schema.js', () => ({
|
||||
clientModel: { findOne: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/sales/salesorder.schema.js', () => ({
|
||||
salesOrderModel: { findOne: jest.fn(), findById: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/sales/listing.schema.js', () => ({
|
||||
listingModel: { findOne: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/sales/listingvarient.schema.js', () => ({
|
||||
listingVarientModel: { findOne: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/sales/marketplace.schema.js', () => ({
|
||||
marketplaceModel: {},
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/inventory/orderitem.schema.js', () => ({
|
||||
orderItemModel: { findOne: jest.fn(), find: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/inventory/shipment.schema.js', () => ({
|
||||
shipmentModel: { findOne: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/sales/marketplaceevent.schema.js', () => ({
|
||||
marketplaceEventModel: { findOne: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/management/productsku.schema.js', () => ({
|
||||
productSkuModel: { findOne: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../database/schemas/management/product.schema.js', () => ({
|
||||
productModel: { findById: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('log4js', () => ({
|
||||
default: { getLogger: () => ({ level: 'off', warn: jest.fn(), info: jest.fn(), debug: jest.fn() }) },
|
||||
}));
|
||||
jest.unstable_mockModule('../../config.js', () => ({
|
||||
default: { server: { logLevel: 'off' } },
|
||||
}));
|
||||
|
||||
const { newObject } = await import('../../database/database.js');
|
||||
const { clientModel } = await import('../../database/schemas/sales/client.schema.js');
|
||||
const { salesOrderModel } = await import('../../database/schemas/sales/salesorder.schema.js');
|
||||
const { orderItemModel } = await import('../../database/schemas/inventory/orderitem.schema.js');
|
||||
const { marketplaceEventModel } = await import(
|
||||
'../../database/schemas/sales/marketplaceevent.schema.js'
|
||||
);
|
||||
const { upsertExternalOrder, applyWebhookAction } = await import('../marketplaceSync.js');
|
||||
const { resolveAuditOwner } = await import('../../auditOwner.js');
|
||||
|
||||
describe('marketplace sync upserts', () => {
|
||||
const marketplace = { _id: 'mkt-1', name: 'eBay UK', config: {} };
|
||||
const actor = { _id: 'mkt-1', name: 'eBay UK', _objectType: 'marketplace' };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates sales orders by externalReference with marketplace actor', async () => {
|
||||
clientModel.findOne.mockResolvedValue(null);
|
||||
salesOrderModel.findOne.mockResolvedValue(null);
|
||||
orderItemModel.findOne.mockResolvedValue(null);
|
||||
newObject.mockImplementation(async ({ newData }) => ({
|
||||
_id: 'created-1',
|
||||
...newData,
|
||||
}));
|
||||
|
||||
const provider = {
|
||||
mapOrderToSalesOrder: () => ({
|
||||
externalReference: 'ebay-order-1',
|
||||
state: { type: 'confirmed' },
|
||||
totalAmount: 10,
|
||||
totalAmountWithTax: 12,
|
||||
shippingAmount: 2,
|
||||
shippingAmountWithTax: 2,
|
||||
grandTotalAmount: 14,
|
||||
totalTaxAmount: 2,
|
||||
}),
|
||||
mapBuyerToClient: () => ({
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
externalReference: 'jane-ebay',
|
||||
}),
|
||||
mapOrderLineItems: () => [
|
||||
{
|
||||
externalReference: 'line-1',
|
||||
name: 'Widget',
|
||||
sku: 'SKU-1',
|
||||
quantity: 1,
|
||||
itemAmount: 10,
|
||||
totalAmount: 10,
|
||||
totalAmountWithTax: 12,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await upsertExternalOrder(
|
||||
marketplace,
|
||||
provider,
|
||||
{ orderId: 'ebay-order-1' },
|
||||
actor
|
||||
);
|
||||
expect(result.action).toBe('created');
|
||||
expect(newObject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: actor,
|
||||
newData: expect.objectContaining({ externalReference: 'ebay-order-1' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips duplicate webhook events', async () => {
|
||||
marketplaceEventModel.findOne.mockResolvedValue({
|
||||
_id: 'evt-1',
|
||||
status: 'processed',
|
||||
});
|
||||
const classified = await applyWebhookAction(
|
||||
marketplace,
|
||||
{},
|
||||
{ action: 'orderUpdate', orderId: 'o1', notificationId: 'n1' },
|
||||
actor
|
||||
);
|
||||
expect(classified.duplicate).toBe(true);
|
||||
expect(newObject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit owner resolution', () => {
|
||||
it('uses marketplace ownerType for marketplace actors', () => {
|
||||
expect(resolveAuditOwner({ _id: 'm1', _objectType: 'marketplace' })).toEqual({
|
||||
owner: 'm1',
|
||||
ownerType: 'marketplace',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to user', () => {
|
||||
expect(resolveAuditOwner({ _id: 'u1' })).toEqual({ owner: 'u1', ownerType: 'user' });
|
||||
});
|
||||
});
|
||||
482
src/integrations/marketplaceSync.js
Normal file
482
src/integrations/marketplaceSync.js
Normal file
@ -0,0 +1,482 @@
|
||||
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 { orderItemModel } from '../database/schemas/inventory/orderitem.schema.js';
|
||||
import { shipmentModel } from '../database/schemas/inventory/shipment.schema.js';
|
||||
import { marketplaceEventModel } from '../database/schemas/sales/marketplaceevent.schema.js';
|
||||
import { productSkuModel } from '../database/schemas/management/productsku.schema.js';
|
||||
import { productModel } from '../database/schemas/management/product.schema.js';
|
||||
import { editObject, newObject } from '../database/database.js';
|
||||
import { marketplaceActor, marketplaceSku } from './marketplaces/ids.js';
|
||||
import config from '../config.js';
|
||||
import log4js from 'log4js';
|
||||
|
||||
const logger = log4js.getLogger('Marketplace Sync');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
function externalIdOf(mapped = {}) {
|
||||
return mapped.externalReference || mapped.externalId;
|
||||
}
|
||||
|
||||
function stateType(mapped) {
|
||||
return mapped?.state?.type || mapped?.state;
|
||||
}
|
||||
|
||||
async function upsertClient(marketplace, clientData, actor) {
|
||||
if (!clientData) return null;
|
||||
const marketplaceId = marketplace._id;
|
||||
let client = null;
|
||||
const externalReference = externalIdOf(clientData);
|
||||
if (externalReference) {
|
||||
client = await clientModel.findOne({ marketplace: marketplaceId, externalReference });
|
||||
}
|
||||
if (!client && clientData.email) {
|
||||
client = await clientModel.findOne({ marketplace: marketplaceId, email: clientData.email });
|
||||
}
|
||||
if (!client && clientData.name) {
|
||||
client = await clientModel.findOne({ marketplace: marketplaceId, name: clientData.name });
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...clientData,
|
||||
marketplace: marketplaceId,
|
||||
active: true,
|
||||
...(externalReference ? { externalReference } : {}),
|
||||
};
|
||||
delete payload.externalId;
|
||||
|
||||
if (client) {
|
||||
return editObject({
|
||||
model: clientModel,
|
||||
id: client._id,
|
||||
updateData: payload,
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
|
||||
return newObject({
|
||||
model: clientModel,
|
||||
newData: payload,
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertOrderItems(salesOrder, externalOrder, provider, actor) {
|
||||
if (typeof provider.mapOrderLineItems !== 'function') return;
|
||||
const lines = provider.mapOrderLineItems(externalOrder) || [];
|
||||
for (const line of lines) {
|
||||
const lineRef = externalIdOf(line);
|
||||
if (!lineRef) continue;
|
||||
|
||||
let listingVarient = null;
|
||||
if (line.sku) {
|
||||
listingVarient = await listingVarientModel.findOne({
|
||||
$or: [{ externalReference: line.sku }, { _reference: line.sku }],
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
orderType: 'salesOrder',
|
||||
order: salesOrder._id,
|
||||
name: line.name || line.sku || 'Marketplace item',
|
||||
itemType: line.itemType || 'product',
|
||||
quantity: line.quantity || 1,
|
||||
itemAmount: line.itemAmount ?? 0,
|
||||
totalAmount: line.totalAmount ?? 0,
|
||||
totalAmountWithTax: line.totalAmountWithTax ?? line.totalAmount ?? 0,
|
||||
externalReference: lineRef,
|
||||
listing: listingVarient?.listing,
|
||||
listingVarient: listingVarient?._id,
|
||||
item: listingVarient?.product,
|
||||
sku: listingVarient?.productSku,
|
||||
state: { type: stateType(salesOrder) === 'cancelled' ? 'cancelled' : 'ordered' },
|
||||
};
|
||||
|
||||
const existing = await orderItemModel.findOne({
|
||||
order: salesOrder._id,
|
||||
externalReference: lineRef,
|
||||
});
|
||||
if (existing) {
|
||||
await editObject({
|
||||
model: orderItemModel,
|
||||
id: existing._id,
|
||||
updateData: payload,
|
||||
user: actor,
|
||||
});
|
||||
} else {
|
||||
await newObject({
|
||||
model: orderItemModel,
|
||||
newData: payload,
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertOrderShipments(salesOrder, externalOrder, provider, actor) {
|
||||
if (typeof provider.mapOrderShipments !== 'function') return;
|
||||
const shipments = provider.mapOrderShipments(externalOrder) || [];
|
||||
for (const mapped of shipments) {
|
||||
const shipmentRef = externalIdOf(mapped);
|
||||
if (!shipmentRef) continue;
|
||||
const payload = {
|
||||
orderType: 'salesOrder',
|
||||
order: salesOrder._id,
|
||||
trackingNumber: mapped.trackingNumber || '',
|
||||
amount: mapped.amount ?? 0,
|
||||
amountWithTax: mapped.amountWithTax ?? mapped.amount ?? 0,
|
||||
externalReference: shipmentRef,
|
||||
state: mapped.state || { type: 'planned' },
|
||||
shippedAt: mapped.shippedAt,
|
||||
};
|
||||
|
||||
const existing = await shipmentModel.findOne({
|
||||
order: salesOrder._id,
|
||||
externalReference: shipmentRef,
|
||||
});
|
||||
if (existing) {
|
||||
await editObject({
|
||||
model: shipmentModel,
|
||||
id: existing._id,
|
||||
updateData: payload,
|
||||
user: actor,
|
||||
});
|
||||
} else {
|
||||
await newObject({
|
||||
model: shipmentModel,
|
||||
newData: payload,
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertExternalOrder(marketplace, provider, externalOrder, actor) {
|
||||
const mapped = provider.mapOrderToSalesOrder(externalOrder);
|
||||
const clientData = provider.mapBuyerToClient(externalOrder);
|
||||
const externalReference = externalIdOf(mapped);
|
||||
if (!externalReference) {
|
||||
throw new Error('Mapped sales order is missing externalReference');
|
||||
}
|
||||
|
||||
const client = await upsertClient(marketplace, clientData, actor);
|
||||
const existingOrder = await salesOrderModel.findOne({
|
||||
marketplace: marketplace._id,
|
||||
externalReference,
|
||||
});
|
||||
|
||||
const lifecycle = {};
|
||||
const type = mapped.state?.type;
|
||||
if (type === 'confirmed' || type === 'shipped' || type === 'delivered') {
|
||||
lifecycle.confirmedAt = existingOrder?.confirmedAt || new Date();
|
||||
}
|
||||
if (type === 'cancelled') {
|
||||
lifecycle.cancelledAt = existingOrder?.cancelledAt || new Date();
|
||||
}
|
||||
|
||||
const orderPayload = {
|
||||
client: client?._id,
|
||||
marketplace: marketplace._id,
|
||||
externalReference,
|
||||
state: mapped.state,
|
||||
totalAmount: mapped.totalAmount,
|
||||
totalAmountWithTax: mapped.totalAmountWithTax,
|
||||
shippingAmount: mapped.shippingAmount,
|
||||
shippingAmountWithTax: mapped.shippingAmountWithTax,
|
||||
grandTotalAmount: mapped.grandTotalAmount,
|
||||
totalTaxAmount: mapped.totalTaxAmount,
|
||||
...lifecycle,
|
||||
};
|
||||
|
||||
let salesOrder = existingOrder;
|
||||
if (existingOrder) {
|
||||
salesOrder = await editObject({
|
||||
model: salesOrderModel,
|
||||
id: existingOrder._id,
|
||||
updateData: orderPayload,
|
||||
user: actor,
|
||||
});
|
||||
} else {
|
||||
salesOrder = await newObject({
|
||||
model: salesOrderModel,
|
||||
newData: orderPayload,
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
|
||||
await upsertOrderItems(salesOrder, externalOrder, provider, actor);
|
||||
await upsertOrderShipments(salesOrder, externalOrder, provider, actor);
|
||||
return { salesOrder, action: existingOrder ? 'updated' : 'created', externalReference };
|
||||
}
|
||||
|
||||
async function matchProductSku(mapped) {
|
||||
const skuCodes = [
|
||||
mapped.externalReference,
|
||||
...(mapped.varients || []).map((v) => v.externalReference || v._reference),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const code of skuCodes) {
|
||||
const sku = await productSkuModel.findOne({
|
||||
$or: [{ _reference: code }, { barcode: code }],
|
||||
});
|
||||
if (sku) return sku;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function upsertInboundListing(marketplace, mapped, actor) {
|
||||
const externalReference = externalIdOf(mapped);
|
||||
if (!externalReference) {
|
||||
logger.warn('Skipping inbound listing without externalReference');
|
||||
return { action: 'skipped' };
|
||||
}
|
||||
|
||||
const defaults = marketplace.config?.defaults || {};
|
||||
const productSku = await matchProductSku(mapped);
|
||||
const product = productSku
|
||||
? await productModel.findById(productSku.product).lean()
|
||||
: null;
|
||||
|
||||
const existing = await listingModel.findOne({
|
||||
marketplace: marketplace._id,
|
||||
externalReference,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
title: mapped.title,
|
||||
url: mapped.url,
|
||||
price: mapped.price,
|
||||
currency: mapped.currency,
|
||||
description: mapped.description,
|
||||
externalReference,
|
||||
lastSyncedAt: new Date(),
|
||||
state: mapped.state || { type: 'active' },
|
||||
marketplace: marketplace._id,
|
||||
};
|
||||
|
||||
if (product) {
|
||||
payload.product = product._id;
|
||||
payload.vendor = product.vendor;
|
||||
}
|
||||
|
||||
let listing = existing;
|
||||
if (existing) {
|
||||
listing = await editObject({
|
||||
model: listingModel,
|
||||
id: existing._id,
|
||||
updateData: payload,
|
||||
user: actor,
|
||||
});
|
||||
} else {
|
||||
const vendor = payload.vendor || defaults.vendor;
|
||||
const stockLocation = defaults.stockLocation;
|
||||
const courierServices = defaults.courierServices || [];
|
||||
if (!vendor || !stockLocation) {
|
||||
logger.warn(
|
||||
`Skipping inbound listing ${externalReference}: marketplace defaults.vendor and defaults.stockLocation are required to create listings`
|
||||
);
|
||||
return { action: 'skipped', externalReference };
|
||||
}
|
||||
listing = await newObject({
|
||||
model: listingModel,
|
||||
newData: {
|
||||
...payload,
|
||||
vendor,
|
||||
stockLocation,
|
||||
courierServices,
|
||||
},
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
|
||||
for (const mappedVarient of mapped.varients || []) {
|
||||
const varientRef = mappedVarient.externalReference || mappedVarient._reference;
|
||||
if (!varientRef) continue;
|
||||
const varientPayload = {
|
||||
listing: listing._id,
|
||||
externalReference: varientRef,
|
||||
price: mappedVarient.price,
|
||||
currency: mappedVarient.currency,
|
||||
state: mappedVarient.state || mapped.state || { type: 'active' },
|
||||
lastSyncedAt: new Date(),
|
||||
product: product?._id || listing.product,
|
||||
productSku: productSku?._id,
|
||||
};
|
||||
const existingVarient = await listingVarientModel.findOne({
|
||||
listing: listing._id,
|
||||
externalReference: varientRef,
|
||||
});
|
||||
if (existingVarient) {
|
||||
await editObject({
|
||||
model: listingVarientModel,
|
||||
id: existingVarient._id,
|
||||
updateData: varientPayload,
|
||||
user: actor,
|
||||
});
|
||||
} else {
|
||||
await newObject({
|
||||
model: listingVarientModel,
|
||||
newData: varientPayload,
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { action: existing ? 'updated' : 'created', listing };
|
||||
}
|
||||
|
||||
export async function importExternalItems(marketplace, provider, actor) {
|
||||
if (typeof provider.syncItems !== 'function' || typeof provider.mapProductToListing !== 'function') {
|
||||
return [];
|
||||
}
|
||||
const externalItems = await provider.syncItems(marketplace);
|
||||
const results = [];
|
||||
for (const item of externalItems || []) {
|
||||
try {
|
||||
const mapped = provider.mapProductToListing(item);
|
||||
const result = await upsertInboundListing(marketplace, mapped, actor);
|
||||
results.push(result);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to import marketplace listing: ${err.message}`);
|
||||
results.push({ action: 'error', error: err.message });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function recordMarketplaceEvent(marketplace, classified, actor) {
|
||||
const externalReference =
|
||||
classified.notificationId ||
|
||||
`${classified.action}:${classified.orderId || classified.itemId || classified.topic || Date.now()}`;
|
||||
const existing = await marketplaceEventModel.findOne({
|
||||
marketplace: marketplace._id,
|
||||
externalReference,
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return newObject({
|
||||
model: marketplaceEventModel,
|
||||
newData: {
|
||||
marketplace: marketplace._id,
|
||||
externalReference,
|
||||
topic: classified.topic || classified.action || 'unknown',
|
||||
status: 'received',
|
||||
},
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
|
||||
export async function markMarketplaceEvent(event, status, actor, error) {
|
||||
if (!event?._id) return;
|
||||
await editObject({
|
||||
model: marketplaceEventModel,
|
||||
id: event._id,
|
||||
updateData: {
|
||||
status,
|
||||
processedAt: status === 'processed' ? new Date() : event.processedAt,
|
||||
error: error || undefined,
|
||||
},
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyWebhookAction(marketplace, provider, classified, actor) {
|
||||
const event = await recordMarketplaceEvent(marketplace, classified, actor);
|
||||
if (event.status === 'processed') {
|
||||
return { ...classified, duplicate: true };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (classified.action) {
|
||||
case 'orderCreate':
|
||||
case 'orderUpdate':
|
||||
case 'orderCancel': {
|
||||
if (classified.orderId && typeof provider.getOrder === 'function') {
|
||||
const order = await provider.getOrder(marketplace, classified.orderId);
|
||||
await upsertExternalOrder(marketplace, provider, order, actor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'productUpdate':
|
||||
case 'productEnded': {
|
||||
await importExternalItems(marketplace, provider, actor);
|
||||
break;
|
||||
}
|
||||
case 'accountDeletion': {
|
||||
if (classified.userId) {
|
||||
const client = await clientModel.findOne({
|
||||
marketplace: marketplace._id,
|
||||
$or: [{ externalReference: classified.userId }, { name: classified.userId }],
|
||||
});
|
||||
if (client) {
|
||||
await editObject({
|
||||
model: clientModel,
|
||||
id: client._id,
|
||||
updateData: {
|
||||
name: 'Deleted marketplace user',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: {},
|
||||
},
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'disconnect': {
|
||||
await editObject({
|
||||
model: marketplaceModel,
|
||||
id: marketplace._id,
|
||||
updateData: { connected: false, state: { type: 'disconnected' } },
|
||||
user: actor,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
await markMarketplaceEvent(event, 'processed', actor);
|
||||
} catch (err) {
|
||||
await markMarketplaceEvent(event, 'failed', actor, err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return classified;
|
||||
}
|
||||
|
||||
export async function pushShipmentFulfillment(marketplace, provider, shipment, actor) {
|
||||
if (typeof provider.createShippingFulfillment !== 'function') {
|
||||
return;
|
||||
}
|
||||
const orderId = shipment.order?._id || shipment.order;
|
||||
const salesOrder = await salesOrderModel.findById(orderId).lean();
|
||||
if (!salesOrder?.externalReference) {
|
||||
return;
|
||||
}
|
||||
const orderItems = await orderItemModel
|
||||
.find({ order: salesOrder._id, shipment: shipment._id, orderType: 'salesOrder' })
|
||||
.lean();
|
||||
await provider.createShippingFulfillment(marketplace, {
|
||||
orderId: salesOrder.externalReference,
|
||||
trackingNumber: shipment.trackingNumber,
|
||||
shippingCarrierCode: shipment.courierService?._reference || shipment.courierService?.code,
|
||||
lineItems: orderItems.map((item) => ({
|
||||
lineItemId: item.externalReference,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
});
|
||||
if (shipment._id && !shipment.externalReference) {
|
||||
await editObject({
|
||||
model: shipmentModel,
|
||||
id: shipment._id,
|
||||
updateData: { lastSyncedAt: new Date() },
|
||||
user: actor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { marketplaceActor, marketplaceSku };
|
||||
15
src/integrations/marketplaces/__tests__/ids.test.js
Normal file
15
src/integrations/marketplaces/__tests__/ids.test.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { marketplaceSku, marketplaceActor } from '../ids.js';
|
||||
|
||||
describe('marketplace ids', () => {
|
||||
it('prefers externalReference over _reference', () => {
|
||||
expect(marketplaceSku({ externalReference: 'ebay-sku', _reference: 'LVR-1' })).toBe('ebay-sku');
|
||||
expect(marketplaceSku({ _reference: 'LVR-1' })).toBe('LVR-1');
|
||||
});
|
||||
|
||||
it('marks marketplace actors for audit logs', () => {
|
||||
const actor = marketplaceActor({ _id: 'm1', name: 'eBay UK' });
|
||||
expect(actor._objectType).toBe('marketplace');
|
||||
expect(actor.name).toBe('eBay UK');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import {
|
||||
buildCategoryTypes,
|
||||
buildFulfillmentPolicy,
|
||||
isDefaultFulfillmentPolicy,
|
||||
validateCourierServices,
|
||||
} from '../fulfillmentPolicies.js';
|
||||
|
||||
const marketplace = { _id: 'mp1', config: { marketplaceId: 'EBAY_GB' } };
|
||||
|
||||
describe('eBay fulfillment policies', () => {
|
||||
it('requires an eBay shipping service code for the listing marketplace', () => {
|
||||
expect(() =>
|
||||
validateCourierServices(
|
||||
[{ name: 'Second Class', active: true, marketplaces: [] }],
|
||||
{ _reference: 'LST-1' },
|
||||
marketplace
|
||||
)
|
||||
).toThrow(
|
||||
'Courier service "Second Class" requires an eBay shipping service code before it can be used on an eBay listing.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the marketplace mapping externalReference as the shipping service code', () => {
|
||||
const services = [
|
||||
{
|
||||
name: 'Second Class',
|
||||
active: true,
|
||||
marketplaces: [
|
||||
{
|
||||
marketplace: { _id: 'mp1' },
|
||||
externalReference: 'UK_RoyalMailSecondClassStandard',
|
||||
},
|
||||
],
|
||||
cost: 2.5,
|
||||
shippingCurrency: 'GBP',
|
||||
},
|
||||
];
|
||||
const policy = buildFulfillmentPolicy(
|
||||
{ _reference: 'LST-1', currency: 'GBP' },
|
||||
marketplace,
|
||||
services
|
||||
);
|
||||
|
||||
expect(policy.shippingOptions[0].shippingServices[0].shippingServiceCode).toBe(
|
||||
'UK_RoyalMailSecondClassStandard'
|
||||
);
|
||||
expect(policy.shippingOptions[0].shippingServices[0].shippingCarrierCode).toBeUndefined();
|
||||
expect(policy.categoryTypes).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }]);
|
||||
expect(policy.categoryTypes[0].default).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits categoryTypes.default on create so the Account API does not receive the deprecated field', () => {
|
||||
expect(buildCategoryTypes()).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }]);
|
||||
});
|
||||
|
||||
it('sends categoryTypes.default=true when the existing policy is the account default', () => {
|
||||
const policy = buildFulfillmentPolicy(
|
||||
{ _reference: 'LST-1', currency: 'GBP' },
|
||||
marketplace,
|
||||
[
|
||||
{
|
||||
name: 'Second Class',
|
||||
active: true,
|
||||
marketplaces: [
|
||||
{
|
||||
marketplace: { _id: 'mp1' },
|
||||
externalReference: 'UK_RoyalMailSecondClassStandard',
|
||||
},
|
||||
],
|
||||
cost: 2.5,
|
||||
shippingCurrency: 'GBP',
|
||||
},
|
||||
],
|
||||
{
|
||||
fulfillmentPolicyId: '6246152000',
|
||||
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: true }],
|
||||
}
|
||||
);
|
||||
|
||||
expect(policy.categoryTypes).toEqual([
|
||||
{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('never sends categoryTypes.default=false, even when GET reports false', () => {
|
||||
expect(
|
||||
buildCategoryTypes({
|
||||
fulfillmentPolicyId: '6246152000',
|
||||
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: false }],
|
||||
})
|
||||
).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }]);
|
||||
});
|
||||
|
||||
it('treats the only fulfillment policy as the account default', () => {
|
||||
expect(
|
||||
isDefaultFulfillmentPolicy(
|
||||
{
|
||||
fulfillmentPolicyId: '6246152000',
|
||||
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: false }],
|
||||
},
|
||||
[{ fulfillmentPolicyId: '6246152000' }]
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
buildCategoryTypes(
|
||||
{
|
||||
fulfillmentPolicyId: '6246152000',
|
||||
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: false }],
|
||||
},
|
||||
[{ fulfillmentPolicyId: '6246152000' }]
|
||||
)
|
||||
).toEqual([{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES', default: true }]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, jest } from '@jest/globals';
|
||||
|
||||
jest.unstable_mockModule('../../../../database/schemas/inventory/stocklocation.schema.js', () => ({
|
||||
stockLocationModel: { findById: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../../../database/schemas/inventory/productstock.schema.js', () => ({
|
||||
productStockModel: { find: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../countryCodes.js', () => ({
|
||||
FARMCONTROL_GB_SUBDIVISION_STATE: {},
|
||||
resolveEbayCountry: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('../categories.js', () => ({ syncProductCategory: jest.fn() }));
|
||||
jest.unstable_mockModule('../fulfillmentPolicies.js', () => ({ syncFulfillmentPolicy: jest.fn() }));
|
||||
jest.unstable_mockModule('../shared.js', () => ({
|
||||
makeRequest: jest.fn(),
|
||||
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../../ids.js', () => ({
|
||||
marketplaceSku: (record) => record?.externalReference || record?._reference,
|
||||
marketplaceActor: (marketplace) => ({ ...marketplace, _objectType: 'marketplace' }),
|
||||
}));
|
||||
|
||||
const { productStockModel } =
|
||||
await import('../../../../database/schemas/inventory/productstock.schema.js');
|
||||
const { makeRequest } = await import('../shared.js');
|
||||
const {
|
||||
fromEbayCondition,
|
||||
resolveListingDescription,
|
||||
resolveVarientQuantity,
|
||||
toEbayCondition,
|
||||
upsertInventoryItem,
|
||||
} = await import('../listingVarients.js');
|
||||
|
||||
describe('eBay varient quantity', () => {
|
||||
it('sums productStock.currentQuantity for the listing stock location', async () => {
|
||||
productStockModel.find.mockReturnValue({
|
||||
lean: async () => [{ currentQuantity: 3 }, { currentQuantity: 5 }],
|
||||
});
|
||||
const quantity = await resolveVarientQuantity(
|
||||
{ productSku: 'sku-1' },
|
||||
{ stockLocation: 'loc-1' }
|
||||
);
|
||||
expect(quantity).toBe(8);
|
||||
expect(productStockModel.find).toHaveBeenCalledWith({
|
||||
productSku: 'sku-1',
|
||||
stockLocation: 'loc-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to varient.inventory when no product SKU is linked', async () => {
|
||||
const quantity = await resolveVarientQuantity({ inventory: 4 }, {});
|
||||
expect(quantity).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eBay listing description', () => {
|
||||
it('prefers listing.description', () => {
|
||||
expect(resolveListingDescription({ description: 'Custom copy', title: 'Widget' })).toBe(
|
||||
'Custom copy'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to title when description is empty', () => {
|
||||
expect(resolveListingDescription({ title: 'Widget' })).toBe('Widget');
|
||||
});
|
||||
|
||||
it('falls back to product SKU description', () => {
|
||||
expect(resolveListingDescription({}, { productSku: { description: 'SKU copy' } })).toBe(
|
||||
'SKU copy'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eBay listing condition', () => {
|
||||
it('converts camelCase listing conditions to eBay ConditionEnum values', () => {
|
||||
expect(toEbayCondition('new')).toBe('NEW');
|
||||
expect(toEbayCondition('likeNew')).toBe('LIKE_NEW');
|
||||
expect(toEbayCondition('forPartsOrNotWorking')).toBe('FOR_PARTS_OR_NOT_WORKING');
|
||||
expect(toEbayCondition('USED_GOOD')).toBe('USED_GOOD');
|
||||
expect(toEbayCondition()).toBe('NEW');
|
||||
});
|
||||
|
||||
it('converts eBay ConditionEnum values back to camelCase', () => {
|
||||
expect(fromEbayCondition('NEW')).toBe('new');
|
||||
expect(fromEbayCondition('LIKE_NEW')).toBe('likeNew');
|
||||
expect(fromEbayCondition('FOR_PARTS_OR_NOT_WORKING')).toBe('forPartsOrNotWorking');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertInventoryItem description', () => {
|
||||
it('always sends product.description and eBay condition to eBay', async () => {
|
||||
makeRequest.mockResolvedValue({});
|
||||
await upsertInventoryItem(
|
||||
{ config: { accessToken: 'token' } },
|
||||
{ _reference: 'SKU-1' },
|
||||
{ title: 'Widget', condition: 'usedGood' }
|
||||
);
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
condition: 'USED_GOOD',
|
||||
product: expect.objectContaining({
|
||||
title: 'Widget',
|
||||
description: 'Widget',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, jest } from '@jest/globals';
|
||||
|
||||
jest.unstable_mockModule('../listingVarients.js', () => ({
|
||||
buildVarientEntry: (item, offers) => ({
|
||||
_reference: item.sku,
|
||||
externalReference: item.sku,
|
||||
price: offers?.[0]?.pricingSummary?.price?.value
|
||||
? parseFloat(offers[0].pricingSummary.price.value)
|
||||
: undefined,
|
||||
currency: offers?.[0]?.pricingSummary?.price?.currency,
|
||||
state: { type: offers?.[0]?.status === 'PUBLISHED' ? 'active' : 'draft' },
|
||||
}),
|
||||
fetchOffers: jest.fn(),
|
||||
fromEbayCondition: (condition) =>
|
||||
condition
|
||||
? condition.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
: undefined,
|
||||
resolveListingDescription: (listing) => listing?.description || listing?.title || '',
|
||||
resolveOfferState: (offers = []) =>
|
||||
offers.some((o) => o?.status === 'PUBLISHED') ? 'active' : 'draft',
|
||||
syncOfferAndMaybePublish: jest.fn(),
|
||||
upsertInventoryItem: jest.fn(),
|
||||
withdrawOfferById: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('../../ids.js', () => ({
|
||||
marketplaceSku: (record) => record?.externalReference || record?._reference,
|
||||
marketplaceActor: (marketplace) => ({ ...marketplace, _objectType: 'marketplace' }),
|
||||
}));
|
||||
jest.unstable_mockModule('../shared.js', () => ({
|
||||
makeRequest: jest.fn(),
|
||||
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
jest.unstable_mockModule('../categories.js', () => ({ syncProductCategory: jest.fn() }));
|
||||
jest.unstable_mockModule('../fulfillmentPolicies.js', () => ({ syncFulfillmentPolicy: jest.fn() }));
|
||||
|
||||
const { mapProductToListing } = await import('../listings.js');
|
||||
|
||||
describe('eBay listing mappers', () => {
|
||||
it('maps grouped inventory to externalReference instead of overwriting _reference', () => {
|
||||
const mapped = mapProductToListing({
|
||||
_type: 'group',
|
||||
_groupKey: 'group-1',
|
||||
_group: { title: 'Widget', description: 'A widget' },
|
||||
_variants: [
|
||||
{
|
||||
sku: 'SKU-RED',
|
||||
_offers: [
|
||||
{
|
||||
listingId: '123456',
|
||||
status: 'PUBLISHED',
|
||||
pricingSummary: { price: { value: '9.99', currency: 'GBP' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mapped.externalReference).toBe('123456');
|
||||
expect(mapped.title).toBe('Widget');
|
||||
expect(mapped.description).toBe('A widget');
|
||||
expect(mapped.varients[0].externalReference).toBe('SKU-RED');
|
||||
expect(mapped._reference).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps standalone inventory items', () => {
|
||||
const mapped = mapProductToListing({
|
||||
sku: 'SKU-BLUE',
|
||||
condition: 'LIKE_NEW',
|
||||
product: { title: 'Blue widget', description: 'Blue' },
|
||||
_offers: [{ listingId: '999', status: 'PUBLISHED', pricingSummary: { price: { value: '4.00', currency: 'GBP' } } }],
|
||||
});
|
||||
expect(mapped.externalReference).toBe('999');
|
||||
expect(mapped.varients[0].externalReference).toBe('SKU-BLUE');
|
||||
expect(mapped.condition).toBe('likeNew');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, jest } from '@jest/globals';
|
||||
import crypto from 'crypto';
|
||||
|
||||
jest.unstable_mockModule('../shared.js', () => ({
|
||||
makeRequest: jest.fn(),
|
||||
getApiBaseUrl: () => 'https://api.sandbox.ebay.com',
|
||||
getBasicAuthHeader: () => 'Basic abc',
|
||||
formatDebugPayload: (value) => value,
|
||||
getMarketplaceDebugContext: () => ({}),
|
||||
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const { makeRequest } = await import('../shared.js');
|
||||
const { buildWebhookChallengeResponse, verifyNotificationSignature } = await import(
|
||||
'../notifications.js'
|
||||
);
|
||||
|
||||
describe('eBay notification challenge', () => {
|
||||
it('hashes challengeCode + verificationToken + endpoint', () => {
|
||||
const marketplace = {
|
||||
config: {
|
||||
verificationToken: 'verify-token-32-characters-long!!',
|
||||
webhookUrl: 'https://example.com/marketplaces/1/hook',
|
||||
},
|
||||
};
|
||||
const challengeCode = 'abc123';
|
||||
const result = buildWebhookChallengeResponse(marketplace, {
|
||||
challengeCode,
|
||||
endpoint: marketplace.config.webhookUrl,
|
||||
});
|
||||
const expected = crypto
|
||||
.createHash('sha256')
|
||||
.update(challengeCode + marketplace.config.verificationToken + marketplace.config.webhookUrl)
|
||||
.digest('hex');
|
||||
expect(result.challengeResponse).toBe(expected);
|
||||
});
|
||||
|
||||
it('throws when challenge inputs are missing', () => {
|
||||
expect(() => buildWebhookChallengeResponse({ config: {} }, { challengeCode: 'x' })).toThrow(
|
||||
/challenge/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eBay notification signature', () => {
|
||||
it('verifies ECDSA signatures with a mocked public key', async () => {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const payload = '{"notification":{"notificationId":"n1"}}';
|
||||
const signature = crypto.createSign('SHA256').update(payload).end().sign(privateKey, 'base64');
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ kid: 'kid-1', signature, digest: 'SHA256' })
|
||||
).toString('base64');
|
||||
makeRequest.mockResolvedValue({
|
||||
key: publicKey.export({ type: 'spki', format: 'pem' }),
|
||||
});
|
||||
|
||||
const valid = await verifyNotificationSignature(
|
||||
{ config: { accessToken: 'token' } },
|
||||
payload,
|
||||
header
|
||||
);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing signature headers', async () => {
|
||||
expect(
|
||||
await verifyNotificationSignature({ config: { accessToken: 'token' } }, '{}', '')
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
118
src/integrations/marketplaces/ebay/__tests__/orders.test.js
Normal file
118
src/integrations/marketplaces/ebay/__tests__/orders.test.js
Normal file
@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import {
|
||||
mapOrderStatus,
|
||||
mapOrderToSalesOrder,
|
||||
mapBuyerToClient,
|
||||
mapOrderLineItems,
|
||||
mapOrderShipments,
|
||||
handleWebhook,
|
||||
} from '../orders.js';
|
||||
|
||||
const ebayOrder = {
|
||||
orderId: '12-34567-89012',
|
||||
orderFulfillmentStatus: 'NOT_STARTED',
|
||||
pricingSummary: {
|
||||
priceSubtotal: { value: '10.00' },
|
||||
deliveryCost: { value: '2.50' },
|
||||
tax: { value: '1.20' },
|
||||
total: { value: '13.70' },
|
||||
},
|
||||
buyer: {
|
||||
username: 'ebay-buyer',
|
||||
buyerRegistrationAddress: { email: 'buyer@example.com' },
|
||||
},
|
||||
fulfillmentStartInstructions: [
|
||||
{
|
||||
shippingStep: {
|
||||
shipTo: {
|
||||
fullName: 'Jane Buyer',
|
||||
primaryPhone: { phoneNumber: '012345' },
|
||||
contactAddress: {
|
||||
addressLine1: '1 High St',
|
||||
city: 'London',
|
||||
postalCode: 'SW1A 1AA',
|
||||
countryCode: 'GB',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
lineItems: [
|
||||
{
|
||||
lineItemId: 'line-1',
|
||||
sku: 'SKU-RED',
|
||||
title: 'Red widget',
|
||||
quantity: 2,
|
||||
lineItemCost: { value: '5.00' },
|
||||
taxes: [{ amount: { value: '1.00' } }],
|
||||
},
|
||||
],
|
||||
shippingFulfillments: [
|
||||
{
|
||||
fulfillmentId: 'ful-1',
|
||||
shipmentTrackingNumber: 'TRACK123',
|
||||
shippedDate: '2026-08-01T10:00:00.000Z',
|
||||
lineItems: [{ lineItemId: 'line-1' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('eBay order mappers', () => {
|
||||
it('maps sales order totals and externalReference', () => {
|
||||
const mapped = mapOrderToSalesOrder(ebayOrder);
|
||||
expect(mapped.externalReference).toBe('12-34567-89012');
|
||||
expect(mapped.totalAmount).toBe(10);
|
||||
expect(mapped.shippingAmount).toBe(2.5);
|
||||
expect(mapped.totalTaxAmount).toBe(1.2);
|
||||
expect(mapped.state.type).toBe('draft');
|
||||
});
|
||||
|
||||
it('maps cancelled orders', () => {
|
||||
expect(
|
||||
mapOrderStatus({ cancelStatus: { cancelState: 'CANCELED' } })
|
||||
).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('maps buyer to client with marketplace buyer id', () => {
|
||||
const client = mapBuyerToClient(ebayOrder);
|
||||
expect(client.externalReference).toBe('ebay-buyer');
|
||||
expect(client.email).toBe('buyer@example.com');
|
||||
expect(client.address.city).toBe('London');
|
||||
});
|
||||
|
||||
it('maps line items', () => {
|
||||
const lines = mapOrderLineItems(ebayOrder);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toMatchObject({
|
||||
externalReference: 'line-1',
|
||||
sku: 'SKU-RED',
|
||||
quantity: 2,
|
||||
itemAmount: 5,
|
||||
totalAmount: 10,
|
||||
totalAmountWithTax: 11,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps shipments from fulfillments', () => {
|
||||
const shipments = mapOrderShipments(ebayOrder);
|
||||
expect(shipments[0]).toMatchObject({
|
||||
externalReference: 'ful-1',
|
||||
trackingNumber: 'TRACK123',
|
||||
});
|
||||
expect(shipments[0].state.type).toBe('shipped');
|
||||
});
|
||||
|
||||
it('classifies commerce notification topics', async () => {
|
||||
const sold = await handleWebhook(
|
||||
{ name: 'eBay' },
|
||||
{ metadata: { topic: 'ORDER_CONFIRMATION' }, notification: { data: { orderId: 'o1' }, notificationId: 'n1' } }
|
||||
);
|
||||
expect(sold).toMatchObject({ action: 'orderUpdate', orderId: 'o1', notificationId: 'n1' });
|
||||
|
||||
const deletion = await handleWebhook(
|
||||
{ name: 'eBay' },
|
||||
{ topic: 'MARKETPLACE_ACCOUNT_DELETION', data: { userId: 'u1' } }
|
||||
);
|
||||
expect(deletion.action).toBe('accountDeletion');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { parseShippingServiceDetails } from '../shippingServices.js';
|
||||
|
||||
const sampleXml = `
|
||||
<GeteBayDetailsResponse xmlns="urn:ebay:apis:eBLBaseComponents">
|
||||
<Ack>Success</Ack>
|
||||
<ShippingServiceDetails>
|
||||
<Description>Royal Mail 1st Class</Description>
|
||||
<ShippingService>UK_RoyalMailFirstClassStandard</ShippingService>
|
||||
<ValidForSellingFlow>true</ValidForSellingFlow>
|
||||
</ShippingServiceDetails>
|
||||
<ShippingServiceDetails>
|
||||
<Description>Deprecated</Description>
|
||||
<ShippingService>UK_OldService</ShippingService>
|
||||
<ValidForSellingFlow>false</ValidForSellingFlow>
|
||||
</ShippingServiceDetails>
|
||||
<ShippingServiceDetails>
|
||||
<Description>Royal Mail 2nd Class</Description>
|
||||
<ShippingService>UK_RoyalMailSecondClassStandard</ShippingService>
|
||||
<ValidForSellingFlow>true</ValidForSellingFlow>
|
||||
</ShippingServiceDetails>
|
||||
</GeteBayDetailsResponse>
|
||||
`;
|
||||
|
||||
describe('eBay shipping service details', () => {
|
||||
it('extracts selling-flow shipping service codes', () => {
|
||||
expect(parseShippingServiceDetails(sampleXml)).toEqual([
|
||||
'UK_RoyalMailFirstClassStandard',
|
||||
'UK_RoyalMailSecondClassStandard',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when no details are present', () => {
|
||||
expect(parseShippingServiceDetails('<Ack>Success</Ack>')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -8,12 +8,21 @@ import {
|
||||
isAccessTokenExpired,
|
||||
getRequiredAuthConfig,
|
||||
getBasicAuthHeader,
|
||||
formatDebugPayload,
|
||||
getMarketplaceDebugContext,
|
||||
logger,
|
||||
} from './shared.js';
|
||||
|
||||
const TOKEN_PATH = '/identity/v1/oauth2/token';
|
||||
|
||||
async function mintToken(marketplace, body) {
|
||||
logger.debug('eBay token request', {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
grantType: body.grant_type,
|
||||
scope: body.scope,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
const response = await fetch(`${getApiBaseUrl(marketplace)}${TOKEN_PATH}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -24,12 +33,26 @@ async function mintToken(marketplace, body) {
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (!response.ok || data.error) {
|
||||
const message = data.error_description || data.error || response.statusText;
|
||||
logger.error(`eBay token request failed: ${message}`);
|
||||
logger.error(`eBay token request failed: ${message}`, {
|
||||
status: response.status,
|
||||
durationMs,
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
response: formatDebugPayload(data),
|
||||
});
|
||||
throw new Error(`eBay token request failed: ${message}`);
|
||||
}
|
||||
|
||||
logger.debug(`eBay token request succeeded (${durationMs}ms)`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
grantType: body.grant_type,
|
||||
expiresIn: data.expires_in,
|
||||
tokenType: data.token_type,
|
||||
hasRefreshToken: !!data.refresh_token,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -124,9 +147,18 @@ export async function refreshAuth(marketplace) {
|
||||
|
||||
export async function ensureAuthenticatedMarketplace(marketplace) {
|
||||
if (!isAccessTokenExpired(marketplace)) {
|
||||
logger.debug(`eBay access token still valid for "${marketplace.name}"`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
expiresAt: marketplace.config?.accessTokenExpiresAt,
|
||||
});
|
||||
return { marketplace };
|
||||
}
|
||||
|
||||
logger.debug(`eBay access token expired for "${marketplace.name}", refreshing`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
expiresAt: marketplace.config?.accessTokenExpiresAt,
|
||||
});
|
||||
|
||||
const authResult = await refreshAuth(marketplace);
|
||||
return {
|
||||
marketplace: {
|
||||
|
||||
@ -3,6 +3,7 @@ import { courierServiceModel } from '../../../database/schemas/management/courie
|
||||
import { makeRequest, logger } from './shared.js';
|
||||
|
||||
const SELLING_POLICY_PROGRAM = 'SELLING_POLICY_MANAGEMENT';
|
||||
const FULFILLMENT_CATEGORY_TYPE = 'ALL_EXCLUDING_MOTORS_VEHICLES';
|
||||
|
||||
async function fetchOptedInPrograms(marketplace) {
|
||||
const result = await makeRequest({
|
||||
@ -42,11 +43,7 @@ function isPopulatedCourierService(service) {
|
||||
service &&
|
||||
typeof service === 'object' &&
|
||||
!(service instanceof mongoose.Types.ObjectId) &&
|
||||
service.name != null &&
|
||||
service.courier &&
|
||||
typeof service.courier === 'object' &&
|
||||
!(service.courier instanceof mongoose.Types.ObjectId) &&
|
||||
service.courier._reference
|
||||
service.name != null
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,14 +59,45 @@ async function resolveCourierServices(listing) {
|
||||
const serviceIds = serviceRefs.map((service) => service?._id || service).filter(Boolean);
|
||||
const services = await courierServiceModel
|
||||
.find({ _id: { $in: serviceIds } })
|
||||
.populate('courier')
|
||||
.populate(['courier', 'marketplaces.marketplace'])
|
||||
.lean();
|
||||
const servicesById = new Map(services.map((service) => [String(service._id), service]));
|
||||
|
||||
return serviceIds.map((id) => servicesById.get(String(id))).filter(Boolean);
|
||||
}
|
||||
|
||||
function validateCourierServices(services, listing) {
|
||||
function idOf(value) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'object') return String(value._id || '');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function getCourierServiceMarketplaceMappings(service) {
|
||||
if (Array.isArray(service?.marketplaces) && service.marketplaces.length) {
|
||||
return service.marketplaces;
|
||||
}
|
||||
if (service?.marketplace) {
|
||||
return [
|
||||
{
|
||||
marketplace: service.marketplace,
|
||||
externalReference: service.externalReference,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getCourierServiceShippingCode(service, marketplace) {
|
||||
const marketplaceId = idOf(marketplace);
|
||||
const mappings = getCourierServiceMarketplaceMappings(service);
|
||||
if (!marketplaceId) {
|
||||
return mappings[0]?.externalReference || service?.externalReference || '';
|
||||
}
|
||||
const mapping = mappings.find((entry) => idOf(entry?.marketplace) === marketplaceId);
|
||||
return mapping?.externalReference || '';
|
||||
}
|
||||
|
||||
export function validateCourierServices(services, listing, marketplace) {
|
||||
const activeServices = services.filter((service) => service.active !== false);
|
||||
if (!activeServices.length) {
|
||||
throw new Error(
|
||||
@ -78,14 +106,9 @@ function validateCourierServices(services, listing) {
|
||||
}
|
||||
|
||||
for (const service of activeServices) {
|
||||
if (!service._reference) {
|
||||
if (!getCourierServiceShippingCode(service, marketplace)) {
|
||||
throw new Error(
|
||||
`Courier service "${service.name}" requires a reference containing its eBay shipping service code.`
|
||||
);
|
||||
}
|
||||
if (!service.courier?._reference) {
|
||||
throw new Error(
|
||||
`The courier for service "${service.name}" requires a reference containing its eBay shipping carrier code.`
|
||||
`Courier service "${service.name}" requires an eBay shipping service code before it can be used on an eBay listing.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -101,13 +124,12 @@ function validateCourierServices(services, listing) {
|
||||
return activeServices;
|
||||
}
|
||||
|
||||
function buildShippingService(service, index, defaultCurrency) {
|
||||
function buildShippingService(service, index, defaultCurrency, marketplace) {
|
||||
const cost = Number(service.costWithTax ?? service.cost ?? 0);
|
||||
const additionalCost = Number(service.additionalCostWithTax ?? service.additionalCost ?? 0);
|
||||
const shippingService = {
|
||||
sortOrder: index + 1,
|
||||
shippingCarrierCode: service.courier._reference,
|
||||
shippingServiceCode: service._reference,
|
||||
shippingServiceCode: getCourierServiceShippingCode(service, marketplace),
|
||||
buyerResponsibleForShipping: false,
|
||||
freeShipping: cost === 0 && additionalCost === 0,
|
||||
shippingCost: {
|
||||
@ -126,25 +148,71 @@ function buildShippingService(service, index, defaultCurrency) {
|
||||
return shippingService;
|
||||
}
|
||||
|
||||
function buildShippingOption(optionType, services, defaultCurrency) {
|
||||
function buildShippingOption(optionType, services, defaultCurrency, marketplace) {
|
||||
if (!services.length) return null;
|
||||
return {
|
||||
optionType,
|
||||
costType: 'FLAT_RATE',
|
||||
shippingServices: services.map((service, index) =>
|
||||
buildShippingService(service, index, defaultCurrency)
|
||||
buildShippingService(service, index, defaultCurrency, marketplace)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFulfillmentPolicy(listing, marketplace, services) {
|
||||
export function isDefaultFulfillmentPolicy(existingPolicy, allPolicies = []) {
|
||||
if (!existingPolicy?.fulfillmentPolicyId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingId = String(existingPolicy.fulfillmentPolicyId);
|
||||
const sources = [existingPolicy, ...allPolicies];
|
||||
if (
|
||||
sources.some(
|
||||
(policy) =>
|
||||
String(policy?.fulfillmentPolicyId) === existingId &&
|
||||
(policy.categoryTypes || []).some((type) => type?.default === true)
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const uniqueIds = [
|
||||
...new Set(
|
||||
allPolicies
|
||||
.filter((policy) => policy?.fulfillmentPolicyId)
|
||||
.map((policy) => String(policy.fulfillmentPolicyId))
|
||||
),
|
||||
];
|
||||
return uniqueIds.length === 1 && uniqueIds[0] === existingId;
|
||||
}
|
||||
|
||||
export function buildCategoryTypes(existingPolicy, allPolicies = []) {
|
||||
const categoryType = { name: FULFILLMENT_CATEGORY_TYPE };
|
||||
|
||||
// Never send default: false. eBay's GET can report false for the account-default
|
||||
// policy, and updateFulfillmentPolicy then rejects it as changing default status
|
||||
// (20403). Keep the default flag only when this policy is (or must remain) default.
|
||||
if (isDefaultFulfillmentPolicy(existingPolicy, allPolicies)) {
|
||||
categoryType.default = true;
|
||||
}
|
||||
|
||||
return [categoryType];
|
||||
}
|
||||
|
||||
export function buildFulfillmentPolicy(
|
||||
listing,
|
||||
marketplace,
|
||||
services,
|
||||
existingPolicy,
|
||||
allPolicies = []
|
||||
) {
|
||||
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
|
||||
const defaultCurrency = marketplace.config?.currency || listing.currency || 'GBP';
|
||||
const domesticServices = services.filter((service) => !service.international);
|
||||
const internationalServices = services.filter((service) => service.international);
|
||||
const shippingOptions = [
|
||||
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency),
|
||||
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency),
|
||||
buildShippingOption('DOMESTIC', domesticServices, defaultCurrency, marketplace),
|
||||
buildShippingOption('INTERNATIONAL', internationalServices, defaultCurrency, marketplace),
|
||||
].filter(Boolean);
|
||||
const deliveryTime = Math.max(0, ...services.map((service) => Number(service.deliveryTime ?? 1)));
|
||||
|
||||
@ -152,7 +220,7 @@ function buildFulfillmentPolicy(listing, marketplace, services) {
|
||||
name: `FarmControl ${listing._reference}`.slice(0, 64),
|
||||
description: `Managed by FarmControl for listing ${listing._reference}`.slice(0, 250),
|
||||
marketplaceId,
|
||||
categoryTypes: [{ name: 'ALL_EXCLUDING_MOTORS_VEHICLES' }],
|
||||
categoryTypes: buildCategoryTypes(existingPolicy, allPolicies),
|
||||
handlingTime: { value: deliveryTime, unit: 'DAY' },
|
||||
localPickup: false,
|
||||
globalShipping: false,
|
||||
@ -162,18 +230,61 @@ function buildFulfillmentPolicy(listing, marketplace, services) {
|
||||
};
|
||||
}
|
||||
|
||||
function getMarketplaceId(marketplace) {
|
||||
return marketplace.config?.marketplaceId || 'EBAY_GB';
|
||||
}
|
||||
|
||||
async function fetchFulfillmentPolicyByName(marketplace, name) {
|
||||
return makeRequest({
|
||||
marketplace,
|
||||
path: '/sell/account/v1/fulfillment_policy/get_by_policy_name',
|
||||
params: {
|
||||
marketplace_id: marketplace.config?.marketplaceId || 'EBAY_GB',
|
||||
marketplace_id: getMarketplaceId(marketplace),
|
||||
name,
|
||||
},
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchFulfillmentPolicies(marketplace) {
|
||||
const result = await makeRequest({
|
||||
marketplace,
|
||||
path: '/sell/account/v1/fulfillment_policy',
|
||||
params: { marketplace_id: getMarketplaceId(marketplace) },
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
return result?.fulfillmentPolicies || [];
|
||||
}
|
||||
|
||||
function isDefaultStatusError(err) {
|
||||
return /changing the default status/i.test(err?.message || '');
|
||||
}
|
||||
|
||||
async function updateFulfillmentPolicy(marketplace, fulfillmentPolicyId, policy) {
|
||||
const path = `/sell/account/v1/fulfillment_policy/${encodeURIComponent(fulfillmentPolicyId)}`;
|
||||
try {
|
||||
await makeRequest({ marketplace, method: 'PUT', path, body: policy });
|
||||
return;
|
||||
} catch (err) {
|
||||
if (policy.categoryTypes?.[0]?.default === true || !isDefaultStatusError(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Retrying fulfillment policy ${fulfillmentPolicyId} with categoryTypes.default=true`
|
||||
);
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path,
|
||||
body: {
|
||||
...policy,
|
||||
categoryTypes: [{ name: FULFILLMENT_CATEGORY_TYPE, default: true }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncFulfillmentPolicy(marketplace, listing) {
|
||||
await ensureSellingPolicyManagement(marketplace);
|
||||
|
||||
@ -181,19 +292,26 @@ export async function syncFulfillmentPolicy(marketplace, listing) {
|
||||
return { fulfillmentPolicyId: String(listing.fulfillmentPolicyId) };
|
||||
}
|
||||
|
||||
const services = validateCourierServices(await resolveCourierServices(listing), listing);
|
||||
const policy = buildFulfillmentPolicy(listing, marketplace, services);
|
||||
const existingPolicy = await fetchFulfillmentPolicyByName(marketplace, policy.name);
|
||||
const services = validateCourierServices(
|
||||
await resolveCourierServices(listing),
|
||||
listing,
|
||||
marketplace
|
||||
);
|
||||
const policyName = `FarmControl ${listing._reference}`.slice(0, 64);
|
||||
const [existingPolicy, allPolicies] = await Promise.all([
|
||||
fetchFulfillmentPolicyByName(marketplace, policyName),
|
||||
fetchFulfillmentPolicies(marketplace),
|
||||
]);
|
||||
const policy = buildFulfillmentPolicy(
|
||||
listing,
|
||||
marketplace,
|
||||
services,
|
||||
existingPolicy,
|
||||
allPolicies
|
||||
);
|
||||
|
||||
if (existingPolicy?.fulfillmentPolicyId) {
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/account/v1/fulfillment_policy/${encodeURIComponent(
|
||||
existingPolicy.fulfillmentPolicyId
|
||||
)}`,
|
||||
body: policy,
|
||||
});
|
||||
await updateFulfillmentPolicy(marketplace, existingPolicy.fulfillmentPolicyId, policy);
|
||||
logger.info(
|
||||
`Updated eBay fulfillment policy "${policy.name}" (${existingPolicy.fulfillmentPolicyId})`
|
||||
);
|
||||
|
||||
@ -26,5 +26,19 @@ export {
|
||||
mapOrderStatus,
|
||||
mapOrderToSalesOrder,
|
||||
mapBuyerToClient,
|
||||
mapOrderLineItems,
|
||||
mapOrderShipments,
|
||||
getOrder,
|
||||
createShippingFulfillment,
|
||||
handleWebhook,
|
||||
} from './orders.js';
|
||||
|
||||
export {
|
||||
buildWebhookChallengeResponse,
|
||||
ensureWebhookSubscriptions,
|
||||
verifyNotificationSignature,
|
||||
canVerifyNotificationSignature,
|
||||
} from './notifications.js';
|
||||
|
||||
export { makeRequest as debugGet } from './shared.js';
|
||||
export { syncMarketplaceMetadata } from './shippingServices.js';
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { stockLocationModel } from '../../../database/schemas/inventory/stocklocation.schema.js';
|
||||
import { productStockModel } from '../../../database/schemas/inventory/productstock.schema.js';
|
||||
import { FARMCONTROL_GB_SUBDIVISION_STATE, resolveEbayCountry } from './countryCodes.js';
|
||||
import { syncProductCategory } from './categories.js';
|
||||
import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
||||
import { makeRequest, logger } from './shared.js';
|
||||
import { marketplaceSku } from '../ids.js';
|
||||
|
||||
const WAREHOUSE_ADDRESS_DEFAULTS = {
|
||||
GB: { city: 'London', stateOrProvince: 'England', postalCode: 'SW1A 1AA' },
|
||||
@ -22,6 +24,23 @@ const LISTING_STATUS_MAP = {
|
||||
UNPUBLISHED: 'draft',
|
||||
};
|
||||
|
||||
export function toEbayCondition(condition) {
|
||||
if (!condition || typeof condition !== 'string') {
|
||||
return 'NEW';
|
||||
}
|
||||
if (condition.includes('_') || condition === condition.toUpperCase()) {
|
||||
return condition.toUpperCase();
|
||||
}
|
||||
return condition.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
|
||||
}
|
||||
|
||||
export function fromEbayCondition(condition) {
|
||||
if (!condition || typeof condition !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
return condition.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function isPopulatedStockLocation(stockLocation) {
|
||||
if (!stockLocation || typeof stockLocation !== 'object') return false;
|
||||
if (stockLocation instanceof mongoose.Types.ObjectId) return false;
|
||||
@ -175,23 +194,72 @@ function applyMarketplaceOfferDefaults(
|
||||
if (Object.keys(listingPolicies).length) offer.listingPolicies = listingPolicies;
|
||||
}
|
||||
|
||||
function mapVarientToInventoryItem(varient, listing) {
|
||||
export function resolveListingDescription(listing, varient) {
|
||||
const candidates = [
|
||||
listing?.description,
|
||||
varient?.productSku?.description,
|
||||
listing?.title,
|
||||
listing?.product?.name,
|
||||
marketplaceSku(varient),
|
||||
listing?._reference,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return 'No description provided.';
|
||||
}
|
||||
|
||||
function inventoryItemPutBody(existing, listing, sku, varient) {
|
||||
const product = { ...(existing?.product || {}) };
|
||||
product.title = product.title || listing?.title || sku;
|
||||
product.description = resolveListingDescription(listing, varient);
|
||||
const body = { product };
|
||||
if (existing?.availability) body.availability = existing.availability;
|
||||
body.condition = toEbayCondition(listing?.condition || existing?.condition);
|
||||
if (existing?.conditionDescription) body.conditionDescription = existing.conditionDescription;
|
||||
if (existing?.packageWeightAndSize) body.packageWeightAndSize = existing.packageWeightAndSize;
|
||||
return body;
|
||||
}
|
||||
|
||||
async function ensureInventoryItemDescription(marketplace, sku, listing, varient) {
|
||||
const existing = await makeRequest({
|
||||
marketplace,
|
||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
|
||||
body: inventoryItemPutBody(existing, listing, sku, varient),
|
||||
});
|
||||
}
|
||||
|
||||
function mapVarientToInventoryItem(varient, listing, quantity = 0) {
|
||||
const item = {
|
||||
product: { title: listing.title || varient._reference || '' },
|
||||
condition: toEbayCondition(listing?.condition),
|
||||
product: {
|
||||
title: listing.title || marketplaceSku(varient) || '',
|
||||
description: resolveListingDescription(listing, varient),
|
||||
},
|
||||
availability: {
|
||||
shipToLocationAvailability: { quantity: varient.inventory ?? 0 },
|
||||
shipToLocationAvailability: { quantity },
|
||||
},
|
||||
};
|
||||
if (listing.description) item.product.description = listing.description;
|
||||
if (listing.imageUrls?.length) item.product.imageUrls = listing.imageUrls;
|
||||
return item;
|
||||
}
|
||||
|
||||
function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
||||
const offer = {
|
||||
sku: varient._reference,
|
||||
sku: marketplaceSku(varient),
|
||||
marketplaceId: marketplace.config?.marketplaceId || 'EBAY_GB',
|
||||
format: 'FIXED_PRICE',
|
||||
listingDescription: resolveListingDescription(listing, varient),
|
||||
};
|
||||
applyMarketplaceOfferDefaults(
|
||||
offer,
|
||||
@ -213,12 +281,28 @@ function mapVarientToOffer(varient, listing, marketplace, merchantLocationKey) {
|
||||
return offer;
|
||||
}
|
||||
|
||||
export async function resolveVarientQuantity(varient, listing) {
|
||||
const skuId = varient.productSku?._id || varient.productSku;
|
||||
if (!skuId) {
|
||||
return Number(varient.inventory) || 0;
|
||||
}
|
||||
const locationId = listing?.stockLocation?._id || listing?.stockLocation;
|
||||
const filter = { productSku: skuId };
|
||||
if (locationId) {
|
||||
filter.stockLocation = locationId;
|
||||
}
|
||||
const stocks = await productStockModel.find(filter).lean();
|
||||
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
|
||||
}
|
||||
|
||||
export async function upsertInventoryItem(marketplace, varient, listing) {
|
||||
const inventoryItem = mapVarientToInventoryItem(varient, listing);
|
||||
const sku = marketplaceSku(varient);
|
||||
const quantity = await resolveVarientQuantity(varient, listing);
|
||||
const inventoryItem = mapVarientToInventoryItem(varient, listing, quantity);
|
||||
const result = await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(varient._reference)}`,
|
||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
|
||||
body: inventoryItem,
|
||||
});
|
||||
logger.debug('inventoryItem', inventoryItem);
|
||||
@ -242,12 +326,15 @@ export async function fetchOffers(marketplace, sku) {
|
||||
|
||||
async function upsertOrCreateOffer(marketplace, varient, listing) {
|
||||
const merchantLocationKey = await ensureMerchantLocation(marketplace, listing);
|
||||
const offers = await fetchOffers(marketplace, varient._reference);
|
||||
const offers = await fetchOffers(marketplace, marketplaceSku(varient));
|
||||
const existingOffer = offers[0];
|
||||
const price = varient.price ?? listing.price;
|
||||
|
||||
if (existingOffer?.offerId) {
|
||||
const offerUpdate = { merchantLocationKey };
|
||||
const offerUpdate = {
|
||||
merchantLocationKey,
|
||||
listingDescription: resolveListingDescription(listing, varient),
|
||||
};
|
||||
if (price != null) {
|
||||
offerUpdate.pricingSummary = {
|
||||
price: {
|
||||
@ -309,15 +396,18 @@ export async function syncOfferAndMaybePublish(marketplace, listing, varient) {
|
||||
try {
|
||||
const publishResult = await publishOfferById(marketplace, offerResult.offerId);
|
||||
if (publishResult?.listingId) {
|
||||
return `https://www.ebay.com/itm/${publishResult.listingId}`;
|
||||
return {
|
||||
url: `https://www.ebay.com/itm/${publishResult.listingId}`,
|
||||
listingId: publishResult.listingId,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Created offer but failed to publish for varient ${varient._reference}: ${err.message}`
|
||||
`Created offer but failed to publish for varient ${marketplaceSku(varient)}: ${err.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
return { url: '', listingId: offerResult?.listingId };
|
||||
}
|
||||
|
||||
export async function publishOfferForSku(marketplace, sku, listing) {
|
||||
@ -344,6 +434,9 @@ export async function publishOfferForSku(marketplace, sku, listing) {
|
||||
);
|
||||
}
|
||||
|
||||
const listingDescription = resolveListingDescription(listing, { _reference: sku });
|
||||
await ensureInventoryItemDescription(marketplace, sku, listing, { _reference: sku });
|
||||
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'PUT',
|
||||
@ -352,6 +445,7 @@ export async function publishOfferForSku(marketplace, sku, listing) {
|
||||
...existingOffer,
|
||||
merchantLocationKey,
|
||||
categoryId: String(category.categoryId),
|
||||
listingDescription,
|
||||
listingPolicies: {
|
||||
...(existingOffer.listingPolicies || {}),
|
||||
fulfillmentPolicyId: fulfillmentPolicy.fulfillmentPolicyId,
|
||||
@ -387,6 +481,7 @@ export function buildVarientEntry(item, offers) {
|
||||
const offer = offers?.[0];
|
||||
return {
|
||||
_reference: item.sku,
|
||||
externalReference: item.sku,
|
||||
price: offer?.pricingSummary?.price?.value
|
||||
? parseFloat(offer.pricingSummary.price.value)
|
||||
: undefined,
|
||||
|
||||
@ -3,11 +3,15 @@ import { syncFulfillmentPolicy } from './fulfillmentPolicies.js';
|
||||
import {
|
||||
buildVarientEntry,
|
||||
fetchOffers,
|
||||
fromEbayCondition,
|
||||
resolveListingDescription,
|
||||
resolveOfferState,
|
||||
syncOfferAndMaybePublish,
|
||||
upsertInventoryItem,
|
||||
withdrawOfferById,
|
||||
} from './listingVarients.js';
|
||||
import { makeRequest, logger } from './shared.js';
|
||||
import { marketplaceSku } from '../ids.js';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@ -15,17 +19,14 @@ function sleep(ms) {
|
||||
|
||||
async function createOrReplaceGroup(marketplace, listing, varients) {
|
||||
const groupKey = listing._reference;
|
||||
const variantSKUs = varients.map((v) => v._reference).filter(Boolean);
|
||||
const variantSKUs = varients.map((v) => marketplaceSku(v)).filter(Boolean);
|
||||
|
||||
const body = {
|
||||
title: listing.title || groupKey,
|
||||
variantSKUs,
|
||||
description: resolveListingDescription(listing),
|
||||
};
|
||||
|
||||
if (listing.description) {
|
||||
body.description = listing.description;
|
||||
}
|
||||
|
||||
if (listing.imageUrls?.length) {
|
||||
body.imageUrls = listing.imageUrls;
|
||||
}
|
||||
@ -78,17 +79,17 @@ async function syncGroupedListing(marketplace, listing, varients) {
|
||||
await sleep(1000);
|
||||
await createOrReplaceGroup(marketplace, listing, varients);
|
||||
|
||||
let firstPublishedUrl = '';
|
||||
let firstPublished = { url: '' };
|
||||
for (const varient of varients) {
|
||||
try {
|
||||
const publishedUrl = await syncOfferAndMaybePublish(marketplace, listing, varient);
|
||||
if (publishedUrl && !firstPublishedUrl) firstPublishedUrl = publishedUrl;
|
||||
const published = await syncOfferAndMaybePublish(marketplace, listing, varient);
|
||||
if (published?.url && !firstPublished.url) firstPublished = published;
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to create offer for varient ${varient._reference}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return firstPublishedUrl;
|
||||
return firstPublished;
|
||||
}
|
||||
|
||||
async function syncListing(marketplace, listing, varients, actionLabel) {
|
||||
@ -116,12 +117,25 @@ async function syncListing(marketplace, listing, varients, actionLabel) {
|
||||
logger.info(
|
||||
`Syncing standalone eBay inventory item "${validVarients[0]._reference}" for listing "${ref}"`
|
||||
);
|
||||
const url = await syncSingleVarientListing(marketplace, listingWithContext, validVarients[0]);
|
||||
return { url };
|
||||
const published = await syncSingleVarientListing(
|
||||
marketplace,
|
||||
listingWithContext,
|
||||
validVarients[0]
|
||||
);
|
||||
return listingSyncResult(published);
|
||||
}
|
||||
|
||||
const url = await syncGroupedListing(marketplace, listingWithContext, validVarients);
|
||||
return { url };
|
||||
const published = await syncGroupedListing(marketplace, listingWithContext, validVarients);
|
||||
return listingSyncResult(published);
|
||||
}
|
||||
|
||||
function listingSyncResult(published) {
|
||||
if (!published) return { url: '' };
|
||||
if (typeof published === 'string') return { url: published };
|
||||
return {
|
||||
url: published.url || '',
|
||||
...(published.listingId ? { externalReference: published.listingId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createItem(marketplace, listing, varients) {
|
||||
@ -132,12 +146,37 @@ 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;
|
||||
export async function deleteItem(marketplace, listing, varients = []) {
|
||||
const skus = varients.map((v) => marketplaceSku(v)).filter(Boolean);
|
||||
|
||||
for (const sku of skus) {
|
||||
const offers = await fetchOffers(marketplace, sku);
|
||||
for (const offer of offers) {
|
||||
if (!offer?.offerId) continue;
|
||||
try {
|
||||
await withdrawOfferById(marketplace, offer.offerId);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to withdraw offer ${offer.offerId} for SKU ${sku}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await makeRequest({
|
||||
marketplace,
|
||||
method: 'DELETE',
|
||||
path: `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`,
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to delete eBay inventory item "${sku}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ref = listing._reference;
|
||||
if (ref) {
|
||||
logger.info(`Deleting eBay inventory item group "${ref}"`);
|
||||
await deleteGroup(marketplace, ref);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sync helpers (inbound from eBay) ---
|
||||
@ -278,8 +317,10 @@ export function mapProductToListing(ebayItem) {
|
||||
const varients = variants.map((v) => buildVarientEntry(v, v._offers || []));
|
||||
|
||||
return {
|
||||
_reference: ebayItem._groupKey,
|
||||
externalReference: firstPublishedOffer?.listingId || ebayItem._groupKey,
|
||||
title: group.title || ebayItem._groupKey,
|
||||
description: group.description,
|
||||
condition: fromEbayCondition(variants[0]?.condition),
|
||||
state: { type: stateType },
|
||||
price,
|
||||
currency,
|
||||
@ -301,8 +342,10 @@ export function mapProductToListing(ebayItem) {
|
||||
const varients = [buildVarientEntry(ebayItem, ebayItem._offers || [])];
|
||||
|
||||
return {
|
||||
_reference: ebayItem.sku,
|
||||
externalReference: offer?.listingId || ebayItem.sku,
|
||||
title: ebayItem.product?.title || ebayItem.sku,
|
||||
description: ebayItem.product?.description,
|
||||
condition: fromEbayCondition(ebayItem.condition),
|
||||
state: { type: stateType },
|
||||
price,
|
||||
currency,
|
||||
|
||||
251
src/integrations/marketplaces/ebay/notifications.js
Normal file
251
src/integrations/marketplaces/ebay/notifications.js
Normal file
@ -0,0 +1,251 @@
|
||||
import crypto from 'crypto';
|
||||
import { getApiBaseUrl, getBasicAuthHeader, makeRequest, logger, formatDebugPayload, getMarketplaceDebugContext } from './shared.js';
|
||||
|
||||
const NOTIFICATION_BASE = '/commerce/notification/v1';
|
||||
const APPLICATION_SCOPE =
|
||||
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription';
|
||||
|
||||
const HANDLED_TOPIC_MATCHERS = [
|
||||
'MARKETPLACE_ACCOUNT_DELETION',
|
||||
'AUTHORIZATION_REVOCATION',
|
||||
'ITEM_AVAILABILITY',
|
||||
'ITEM_PRICE_REVISION',
|
||||
'ORDER',
|
||||
];
|
||||
|
||||
function getVerificationToken(marketplace) {
|
||||
return (
|
||||
marketplace.config?.verificationToken ||
|
||||
marketplace.config?.verificationToken ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function getWebhookEndpoint(marketplace) {
|
||||
return marketplace.config?.webhookUrl || '';
|
||||
}
|
||||
|
||||
export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) {
|
||||
const verificationToken = getVerificationToken(marketplace);
|
||||
const destination = endpoint || getWebhookEndpoint(marketplace);
|
||||
if (!challengeCode || !verificationToken || !destination) {
|
||||
throw new Error(
|
||||
'eBay webhook challenge requires challengeCode, verificationToken, and endpoint'
|
||||
);
|
||||
}
|
||||
|
||||
const challengeResponse = crypto
|
||||
.createHash('sha256')
|
||||
.update(challengeCode + verificationToken + destination)
|
||||
.digest('hex');
|
||||
|
||||
return { challengeResponse };
|
||||
}
|
||||
|
||||
async function getApplicationToken(marketplace) {
|
||||
logger.debug('eBay application token request', getMarketplaceDebugContext(marketplace));
|
||||
|
||||
const startedAt = Date.now();
|
||||
const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: getBasicAuthHeader(marketplace),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
scope: APPLICATION_SCOPE,
|
||||
}).toString(),
|
||||
});
|
||||
const data = await response.json();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (!response.ok || data.error) {
|
||||
logger.error('eBay application token request failed', {
|
||||
status: response.status,
|
||||
durationMs,
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
response: formatDebugPayload(data),
|
||||
});
|
||||
throw new Error(data.error_description || data.error || 'Failed to mint eBay application token');
|
||||
}
|
||||
|
||||
logger.debug(`eBay application token request succeeded (${durationMs}ms)`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
expiresIn: data.expires_in,
|
||||
tokenType: data.token_type,
|
||||
});
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async function notificationRequest(marketplace, { method = 'GET', path, body, useApplicationToken = false }) {
|
||||
if (!useApplicationToken) {
|
||||
return makeRequest({ marketplace, method, path, body, acceptableStatuses: [404] });
|
||||
}
|
||||
|
||||
const token = await getApplicationToken(marketplace);
|
||||
const url = `${getApiBaseUrl(marketplace)}${path}`;
|
||||
const startedAt = Date.now();
|
||||
logger.debug(`eBay notification API ${method} ${path}`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
useApplicationToken: true,
|
||||
body: body ? formatDebugPayload(body) : undefined,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (response.status === 204 || response.status === 404) {
|
||||
logger.debug(
|
||||
`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`,
|
||||
getMarketplaceDebugContext(marketplace)
|
||||
);
|
||||
return response.status === 404 ? null : null;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = data?.errors?.[0]?.message || data?.error_description || response.statusText;
|
||||
logger.error(`eBay notification API error (${response.status}): ${message}`, {
|
||||
path,
|
||||
durationMs,
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
response: data ? formatDebugPayload(data) : undefined,
|
||||
});
|
||||
throw new Error(`eBay notification API error (${response.status}): ${message}`);
|
||||
}
|
||||
|
||||
logger.debug(`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
response: data ? formatDebugPayload(data) : undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function topicIsHandled(topicId = '') {
|
||||
const id = String(topicId).toUpperCase();
|
||||
return HANDLED_TOPIC_MATCHERS.some((matcher) => id.includes(matcher));
|
||||
}
|
||||
|
||||
export async function ensureWebhookSubscriptions(marketplace) {
|
||||
const endpoint = getWebhookEndpoint(marketplace);
|
||||
const verificationToken = getVerificationToken(marketplace);
|
||||
if (!endpoint || !verificationToken) {
|
||||
logger.warn(
|
||||
`Skipping eBay webhook subscription for "${marketplace.name}" (missing webhookUrl or verificationToken)`
|
||||
);
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
const destinations = await notificationRequest(marketplace, {
|
||||
path: `${NOTIFICATION_BASE}/destination`,
|
||||
});
|
||||
let destination = (destinations?.destinations || []).find(
|
||||
(item) => item.deliveryConfig?.endpoint === endpoint
|
||||
);
|
||||
|
||||
if (!destination) {
|
||||
destination = await notificationRequest(marketplace, {
|
||||
method: 'POST',
|
||||
path: `${NOTIFICATION_BASE}/destination`,
|
||||
body: {
|
||||
name: `FarmControl ${marketplace.name}`.slice(0, 200),
|
||||
status: 'ENABLED',
|
||||
deliveryConfig: {
|
||||
endpoint,
|
||||
verificationToken,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const destinationId = destination?.destinationId || destination?.id;
|
||||
if (!destinationId) {
|
||||
throw new Error('Failed to create or resolve eBay notification destination');
|
||||
}
|
||||
|
||||
const topics = await notificationRequest(marketplace, { path: `${NOTIFICATION_BASE}/topic` });
|
||||
const subscriptions = await notificationRequest(marketplace, {
|
||||
path: `${NOTIFICATION_BASE}/subscription`,
|
||||
});
|
||||
const existingTopicIds = new Set(
|
||||
(subscriptions?.subscriptions || []).map((item) => item.topicId)
|
||||
);
|
||||
|
||||
const created = [];
|
||||
for (const topic of topics?.topics || []) {
|
||||
if (topic.status && String(topic.status).toUpperCase() !== 'ENABLED') continue;
|
||||
if (!topicIsHandled(topic.topicId)) continue;
|
||||
if (existingTopicIds.has(topic.topicId)) continue;
|
||||
|
||||
const useApplicationToken = String(topic.scope || '').toUpperCase() === 'APPLICATION';
|
||||
try {
|
||||
await notificationRequest(marketplace, {
|
||||
method: 'POST',
|
||||
path: `${NOTIFICATION_BASE}/subscription`,
|
||||
useApplicationToken,
|
||||
body: {
|
||||
topicId: topic.topicId,
|
||||
status: 'ENABLED',
|
||||
destinationId,
|
||||
payload: { format: 'JSON', schemaVersion: topic.supportedPayloads?.[0]?.schemaVersion || '1.0' },
|
||||
},
|
||||
});
|
||||
created.push(topic.topicId);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to subscribe to eBay topic ${topic.topicId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`eBay webhook destination ${destinationId} ready for "${marketplace.name}" (${created.length} new subscription(s))`
|
||||
);
|
||||
return { destinationId, created };
|
||||
}
|
||||
|
||||
async function getPublicKey(marketplace, kid) {
|
||||
return makeRequest({
|
||||
marketplace,
|
||||
path: `${NOTIFICATION_BASE}/public_key/${encodeURIComponent(kid)}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyNotificationSignature(marketplace, rawBody, signatureHeader) {
|
||||
if (!signatureHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedJson = Buffer.from(signatureHeader, 'base64').toString('utf8');
|
||||
const decoded = JSON.parse(decodedJson);
|
||||
const kid = decoded.kid;
|
||||
const signature = decoded.signature;
|
||||
if (!kid || !signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const publicKey = await getPublicKey(marketplace, kid);
|
||||
const pem = publicKey?.key;
|
||||
if (!pem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const digest = (decoded.digest || publicKey.digest || 'SHA256').replace('-', '');
|
||||
const verifier = crypto.createVerify(digest);
|
||||
verifier.update(rawBody);
|
||||
verifier.end();
|
||||
return verifier.verify(pem, signature, 'base64');
|
||||
} catch (err) {
|
||||
logger.warn(`eBay notification signature verification failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canVerifyNotificationSignature(marketplace) {
|
||||
return !!marketplace.config?.accessToken;
|
||||
}
|
||||
@ -90,6 +90,7 @@ export function mapOrderToSalesOrder(ebayOrder) {
|
||||
const grandTotal = parseFloat(pricingSummary.total?.value || 0);
|
||||
|
||||
return {
|
||||
externalReference: ebayOrder.orderId,
|
||||
externalId: ebayOrder.orderId,
|
||||
state: { type: mapOrderStatus(ebayOrder) },
|
||||
totalAmount,
|
||||
@ -112,6 +113,7 @@ export function mapBuyerToClient(ebayOrder) {
|
||||
name: fullName,
|
||||
email: buyer.buyerRegistrationAddress?.email || '',
|
||||
phone: address.primaryPhone?.phoneNumber || '',
|
||||
externalReference: buyer.username || '',
|
||||
address: {
|
||||
addressLine1: contactAddress.addressLine1 || '',
|
||||
addressLine2: contactAddress.addressLine2 || '',
|
||||
@ -123,34 +125,131 @@ export function mapBuyerToClient(ebayOrder) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrder(marketplace, orderId) {
|
||||
const order = await makeRequest({
|
||||
marketplace,
|
||||
path: `/sell/fulfillment/v1/order/${encodeURIComponent(orderId)}`,
|
||||
});
|
||||
const fulfillments = await makeRequest({
|
||||
marketplace,
|
||||
path: `/sell/fulfillment/v1/order/${encodeURIComponent(orderId)}/shipping_fulfillment`,
|
||||
acceptableStatuses: [404],
|
||||
});
|
||||
return {
|
||||
...order,
|
||||
shippingFulfillments: fulfillments?.fulfillments || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapOrderLineItems(ebayOrder) {
|
||||
return (ebayOrder.lineItems || []).map((line) => {
|
||||
const unit = parseFloat(line.lineItemCost?.value || 0);
|
||||
const tax = (line.taxes || []).reduce(
|
||||
(sum, taxLine) => sum + parseFloat(taxLine.amount?.value || 0),
|
||||
0
|
||||
);
|
||||
const quantity = line.quantity || 1;
|
||||
return {
|
||||
externalReference: line.lineItemId,
|
||||
sku: line.sku || '',
|
||||
name: line.title || line.sku || 'Marketplace item',
|
||||
quantity,
|
||||
itemAmount: unit,
|
||||
totalAmount: unit * quantity,
|
||||
totalAmountWithTax: unit * quantity + tax,
|
||||
itemType: 'product',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mapOrderShipments(ebayOrder) {
|
||||
const shippingAmount = parseFloat(ebayOrder.pricingSummary?.deliveryCost?.value || 0);
|
||||
const fulfillments = ebayOrder.shippingFulfillments || [];
|
||||
if (!fulfillments.length) {
|
||||
return [];
|
||||
}
|
||||
const splitAmount = shippingAmount / fulfillments.length;
|
||||
return fulfillments.map((fulfillment) => ({
|
||||
externalReference: fulfillment.fulfillmentId,
|
||||
trackingNumber: fulfillment.shipmentTrackingNumber || '',
|
||||
amount: splitAmount,
|
||||
amountWithTax: splitAmount,
|
||||
state: { type: fulfillment.shippedDate ? 'shipped' : 'planned' },
|
||||
shippedAt: fulfillment.shippedDate ? new Date(fulfillment.shippedDate) : undefined,
|
||||
lineItemIds: (fulfillment.lineItems || []).map((line) => line.lineItemId).filter(Boolean),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createShippingFulfillment(marketplace, { orderId, lineItems, trackingNumber, shippingCarrierCode }) {
|
||||
if (!orderId) {
|
||||
throw new Error('orderId is required to create an eBay shipping fulfillment');
|
||||
}
|
||||
if (!trackingNumber) {
|
||||
throw new Error('trackingNumber is required to create an eBay shipping fulfillment');
|
||||
}
|
||||
|
||||
return makeRequest({
|
||||
marketplace,
|
||||
method: 'POST',
|
||||
path: `/sell/fulfillment/v1/order/${encodeURIComponent(orderId)}/shipping_fulfillment`,
|
||||
body: {
|
||||
lineItems: (lineItems || []).map((line) => ({
|
||||
lineItemId: line.lineItemId || line.externalReference,
|
||||
quantity: line.quantity || 1,
|
||||
})),
|
||||
shippingCarrierCode: shippingCarrierCode || 'Other',
|
||||
trackingNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleWebhook(marketplace, event) {
|
||||
const { topic, data } = event;
|
||||
const topic =
|
||||
event?.metadata?.topic ||
|
||||
event?.topic ||
|
||||
event?.notification?.topic ||
|
||||
'';
|
||||
const data = event?.notification?.data || event?.data || {};
|
||||
const notificationId =
|
||||
event?.notification?.notificationId || event?.notificationId || '';
|
||||
|
||||
logger.info(`eBay webhook received: ${topic} for marketplace ${marketplace.name}`);
|
||||
|
||||
if (topic?.startsWith('marketplace.account_deletion')) {
|
||||
return { action: 'accountDeletion', userId: data?.userId };
|
||||
const orderId = data.orderId || data.order?.orderId || data.legacyOrderId;
|
||||
const sku = data.sku || data.itemId || data.listingId;
|
||||
|
||||
const normalized = String(topic).toUpperCase();
|
||||
|
||||
if (normalized.includes('ACCOUNT_DELETION') || normalized.includes('MARKETPLACE_ACCOUNT_DELETION')) {
|
||||
return {
|
||||
action: 'accountDeletion',
|
||||
userId: data.userId || data.username,
|
||||
notificationId,
|
||||
topic,
|
||||
};
|
||||
}
|
||||
|
||||
switch (topic) {
|
||||
case 'item.sold':
|
||||
return { action: 'orderCreate', orderId: data?.orderId };
|
||||
if (normalized.includes('AUTHORIZATION_REVOCATION')) {
|
||||
return { action: 'disconnect', notificationId, topic };
|
||||
}
|
||||
|
||||
case 'item.created':
|
||||
case 'item.updated':
|
||||
return { action: 'productUpdate', itemId: data?.itemId };
|
||||
if (normalized.includes('ORDER') || normalized === 'ITEM.SOLD' || normalized === 'ORDER.FULFILLMENT') {
|
||||
return {
|
||||
action: orderId && topic.toLowerCase().includes('cancel') ? 'orderCancel' : 'orderUpdate',
|
||||
orderId,
|
||||
notificationId,
|
||||
topic,
|
||||
};
|
||||
}
|
||||
|
||||
case 'item.ended':
|
||||
return { action: 'productEnded', itemId: data?.itemId };
|
||||
if (
|
||||
normalized.includes('ITEM') ||
|
||||
normalized.includes('AVAILABILITY') ||
|
||||
normalized.includes('PRICE')
|
||||
) {
|
||||
return { action: 'productUpdate', itemId: sku, sku, notificationId, topic };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
return { action: 'unknown', topic, notificationId };
|
||||
}
|
||||
|
||||
@ -4,6 +4,35 @@ import log4js from 'log4js';
|
||||
const logger = log4js.getLogger('eBay');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
const DEBUG_PAYLOAD_MAX_LENGTH = 4000;
|
||||
|
||||
function formatDebugPayload(value, { maxLength = DEBUG_PAYLOAD_MAX_LENGTH } = {}) {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
} catch {
|
||||
text = String(value);
|
||||
}
|
||||
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`;
|
||||
}
|
||||
|
||||
function getMarketplaceDebugContext(marketplace) {
|
||||
return {
|
||||
marketplace: marketplace?.name,
|
||||
marketplaceId: marketplace?.config?.marketplaceId,
|
||||
sandbox: marketplace?.config?.sandbox ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
@ -14,6 +43,7 @@ const DEFAULT_SCOPES = [
|
||||
'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',
|
||||
'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription',
|
||||
];
|
||||
const MARKETPLACE_LANGUAGE_MAP = {
|
||||
EBAY_US: 'en-US',
|
||||
@ -144,11 +174,21 @@ export async function makeRequest({
|
||||
fetchOptions.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
logger.debug(`eBay API ${method} ${path}`);
|
||||
const startedAt = Date.now();
|
||||
logger.debug(`eBay API ${method} ${path}`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
params: Object.keys(params).length ? params : undefined,
|
||||
body: body ? formatDebugPayload(body) : undefined,
|
||||
acceptableStatuses: acceptableStatuses.length ? acceptableStatuses : undefined,
|
||||
});
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
if (response.status === 204) {
|
||||
logger.debug(`eBay API ${method} ${path} -> 204 No Content (${durationMs}ms)`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -163,19 +203,40 @@ export async function makeRequest({
|
||||
}
|
||||
|
||||
if (!response.ok && acceptableStatuses.includes(response.status)) {
|
||||
logger.debug(
|
||||
`eBay API ${method} ${path} -> ${response.status} (acceptable) (${durationMs}ms)`,
|
||||
{
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
response: data ? formatDebugPayload(data) : undefined,
|
||||
}
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof data === 'object' && (data?.errors?.[0]?.message || data?.error_description)) ||
|
||||
(typeof data === 'object' &&
|
||||
(data?.errors?.[0]?.longMessage ||
|
||||
data?.errors?.[0]?.message ||
|
||||
data?.error_description)) ||
|
||||
(typeof data === 'string' && data) ||
|
||||
response.statusText;
|
||||
logger.error(`eBay API error: ${message}`, { status: response.status, path });
|
||||
logger.error(`eBay API error: ${message}`, {
|
||||
status: response.status,
|
||||
path,
|
||||
durationMs,
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
response: data ? formatDebugPayload(data) : undefined,
|
||||
});
|
||||
throw new Error(`eBay API error (${response.status}): ${message}`);
|
||||
}
|
||||
|
||||
logger.debug(`eBay API ${method} ${path} -> ${response.status} (${durationMs}ms)`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
response: data ? formatDebugPayload(data) : undefined,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export { logger };
|
||||
export { logger, formatDebugPayload, getMarketplaceDebugContext };
|
||||
|
||||
144
src/integrations/marketplaces/ebay/shippingServices.js
Normal file
144
src/integrations/marketplaces/ebay/shippingServices.js
Normal file
@ -0,0 +1,144 @@
|
||||
import { logger, getMarketplaceDebugContext } from './shared.js';
|
||||
|
||||
const TRADING_COMPATIBILITY_LEVEL = '1399';
|
||||
const TRADING_SANDBOX_URL = 'https://api.sandbox.ebay.com/ws/api.dll';
|
||||
const TRADING_PRODUCTION_URL = 'https://api.ebay.com/ws/api.dll';
|
||||
|
||||
const MARKETPLACE_SITE_IDS = {
|
||||
EBAY_US: '0',
|
||||
EBAY_ENCA: '2',
|
||||
EBAY_GB: '3',
|
||||
EBAY_AU: '15',
|
||||
EBAY_AT: '16',
|
||||
EBAY_FRBE: '23',
|
||||
EBAY_FR: '71',
|
||||
EBAY_DE: '77',
|
||||
EBAY_IT: '101',
|
||||
EBAY_NLBE: '123',
|
||||
EBAY_NL: '146',
|
||||
EBAY_ES: '186',
|
||||
EBAY_CH: '193',
|
||||
EBAY_HK: '201',
|
||||
EBAY_IN: '203',
|
||||
EBAY_IE: '205',
|
||||
EBAY_MY: '207',
|
||||
EBAY_PH: '211',
|
||||
EBAY_PL: '212',
|
||||
EBAY_SG: '216',
|
||||
};
|
||||
|
||||
function getTradingApiUrl(marketplace) {
|
||||
return marketplace.config?.sandbox ? TRADING_SANDBOX_URL : TRADING_PRODUCTION_URL;
|
||||
}
|
||||
|
||||
function getSiteId(marketplace) {
|
||||
const marketplaceId = marketplace.config?.marketplaceId || 'EBAY_GB';
|
||||
return MARKETPLACE_SITE_IDS[marketplaceId] || MARKETPLACE_SITE_IDS.EBAY_GB;
|
||||
}
|
||||
|
||||
export function parseShippingServiceDetails(xml = '') {
|
||||
const blocks = xml.match(/<ShippingServiceDetails>[\s\S]*?<\/ShippingServiceDetails>/g) || [];
|
||||
const services = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const block of blocks) {
|
||||
const validMatch = block.match(/<ValidForSellingFlow>([^<]+)<\/ValidForSellingFlow>/i);
|
||||
if (validMatch && String(validMatch[1]).toLowerCase() !== 'true') {
|
||||
continue;
|
||||
}
|
||||
const serviceMatch = block.match(/<ShippingService>([^<]+)<\/ShippingService>/i);
|
||||
const code = serviceMatch?.[1]?.trim();
|
||||
if (!code || seen.has(code)) continue;
|
||||
seen.add(code);
|
||||
services.push(code);
|
||||
}
|
||||
|
||||
return services.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function buildGeteBayDetailsRequest() {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<GeteBayDetailsRequest xmlns="urn:ebay:apis:eBLBaseComponents">
|
||||
<ErrorLanguage>en_GB</ErrorLanguage>
|
||||
<WarningLevel>High</WarningLevel>
|
||||
<DetailName>ShippingServiceDetails</DetailName>
|
||||
</GeteBayDetailsRequest>`;
|
||||
}
|
||||
|
||||
export async function fetchEbayShippingServices(marketplace) {
|
||||
const accessToken = marketplace.config?.accessToken;
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'eBay marketplace is not authenticated. Complete marketplace authorization first.'
|
||||
);
|
||||
}
|
||||
|
||||
const url = getTradingApiUrl(marketplace);
|
||||
const siteId = getSiteId(marketplace);
|
||||
const startedAt = Date.now();
|
||||
logger.debug('eBay Trading API GeteBayDetails request', {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
siteId,
|
||||
url,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml',
|
||||
'X-EBAY-API-CALL-NAME': 'GeteBayDetails',
|
||||
'X-EBAY-API-SITEID': siteId,
|
||||
'X-EBAY-API-COMPATIBILITY-LEVEL': TRADING_COMPATIBILITY_LEVEL,
|
||||
'X-EBAY-API-IAF-TOKEN': accessToken,
|
||||
},
|
||||
body: buildGeteBayDetailsRequest(),
|
||||
});
|
||||
|
||||
const xml = await response.text();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (!response.ok) {
|
||||
logger.error(`GeteBayDetails failed (${response.status})`, {
|
||||
durationMs,
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
siteId,
|
||||
});
|
||||
throw new Error(`eBay GeteBayDetails error (${response.status})`);
|
||||
}
|
||||
|
||||
const ackMatch = xml.match(/<Ack>([^<]+)<\/Ack>/);
|
||||
const ack = ackMatch?.[1];
|
||||
if (ack && ack !== 'Success' && ack !== 'Warning') {
|
||||
const message =
|
||||
xml.match(/<LongMessage>([^<]+)<\/LongMessage>/)?.[1] ||
|
||||
xml.match(/<ShortMessage>([^<]+)<\/ShortMessage>/)?.[1] ||
|
||||
ack;
|
||||
logger.error(`GeteBayDetails failed: ${message}`, {
|
||||
ack,
|
||||
durationMs,
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
siteId,
|
||||
});
|
||||
throw new Error(`eBay GeteBayDetails failed: ${message}`);
|
||||
}
|
||||
|
||||
const services = parseShippingServiceDetails(xml);
|
||||
logger.debug(`GeteBayDetails succeeded (${durationMs}ms)`, {
|
||||
...getMarketplaceDebugContext(marketplace),
|
||||
siteId,
|
||||
serviceCount: services.length,
|
||||
});
|
||||
logger.info(
|
||||
`Fetched ${services.length} eBay shipping service(s) for marketplace "${marketplace.name}"`
|
||||
);
|
||||
return services;
|
||||
}
|
||||
|
||||
export async function syncMarketplaceMetadata(marketplace) {
|
||||
const availableShippingServices = await fetchEbayShippingServices(marketplace);
|
||||
return {
|
||||
eBay: {
|
||||
...(marketplace.eBay && typeof marketplace.eBay === 'object' ? marketplace.eBay : {}),
|
||||
availableShippingServices,
|
||||
},
|
||||
};
|
||||
}
|
||||
9
src/integrations/marketplaces/ids.js
Normal file
9
src/integrations/marketplaces/ids.js
Normal file
@ -0,0 +1,9 @@
|
||||
export function marketplaceSku(record) {
|
||||
return record?.externalReference || record?._reference;
|
||||
}
|
||||
|
||||
export function marketplaceActor(marketplace) {
|
||||
const plain =
|
||||
typeof marketplace?.toObject === 'function' ? marketplace.toObject() : { ...marketplace };
|
||||
return { ...plain, _objectType: 'marketplace' };
|
||||
}
|
||||
@ -429,8 +429,8 @@ function mapListingToProduct(listing) {
|
||||
};
|
||||
}
|
||||
|
||||
if (listing.externalId) {
|
||||
sku.seller_sku = listing.externalId;
|
||||
if (listing.externalReference || listing.externalId) {
|
||||
sku.seller_sku = listing.externalReference || listing.externalId;
|
||||
}
|
||||
|
||||
product.skus = [sku];
|
||||
@ -452,15 +452,16 @@ export async function createItem(marketplace, listing) {
|
||||
const productId = result?.product_id || result?.id;
|
||||
|
||||
return {
|
||||
externalReference: productId,
|
||||
externalId: productId,
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateItem(marketplace, listing) {
|
||||
const productId = listing.externalId;
|
||||
const productId = listing.externalReference || listing.externalId;
|
||||
if (!productId) {
|
||||
throw new Error('Listing must have an externalId to update on TikTok Shop');
|
||||
throw new Error('Listing must have an externalReference to update on TikTok Shop');
|
||||
}
|
||||
|
||||
logger.info(`Updating TikTok Shop product: ${productId}`);
|
||||
@ -473,7 +474,7 @@ export async function updateItem(marketplace, listing) {
|
||||
body: productBody,
|
||||
});
|
||||
|
||||
return { externalId: productId };
|
||||
return { externalReference: productId, externalId: productId };
|
||||
}
|
||||
|
||||
export async function syncItems(marketplace) {
|
||||
@ -537,6 +538,7 @@ export function mapOrderToSalesOrder(tiktokOrder) {
|
||||
const totalTax = parseFloat(paymentInfo.tax || 0);
|
||||
|
||||
return {
|
||||
externalReference: tiktokOrder.id,
|
||||
externalId: tiktokOrder.id,
|
||||
state: { type: mapOrderStatus(tiktokOrder.status) },
|
||||
totalAmount,
|
||||
@ -579,6 +581,7 @@ export function mapProductToListing(tiktokProduct) {
|
||||
: undefined;
|
||||
return {
|
||||
_reference: sku.id || sku.seller_sku || '',
|
||||
externalReference: sku.seller_sku || sku.id || '',
|
||||
price: skuPrice,
|
||||
currency: sku.price?.currency || currency,
|
||||
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
||||
@ -586,6 +589,7 @@ export function mapProductToListing(tiktokProduct) {
|
||||
});
|
||||
|
||||
return {
|
||||
externalReference: tiktokProduct.id,
|
||||
_reference: tiktokProduct.id,
|
||||
title: tiktokProduct.title || '',
|
||||
state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' },
|
||||
@ -602,6 +606,7 @@ export function mapBuyerToClient(tiktokOrder) {
|
||||
const address = tiktokOrder.recipient_address || {};
|
||||
|
||||
return {
|
||||
externalReference: buyer.open_id || buyer.username || '',
|
||||
name: buyer.name || address.name || 'Unknown',
|
||||
email: buyer.email || '',
|
||||
phone: buyer.phone_number || address.phone_number || '',
|
||||
@ -616,6 +621,42 @@ export function mapBuyerToClient(tiktokOrder) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrder(marketplace, orderId) {
|
||||
return fetchOrderDetail(marketplace, orderId);
|
||||
}
|
||||
|
||||
export function mapOrderLineItems(tiktokOrder) {
|
||||
return (tiktokOrder.line_items || []).map((line) => {
|
||||
const quantity = Number(line.quantity || 1);
|
||||
const unit = parseFloat(line.sale_price || line.original_price || 0);
|
||||
return {
|
||||
externalReference: String(line.id || line.sku_id || ''),
|
||||
sku: line.seller_sku || line.sku_id || '',
|
||||
name: line.product_name || line.sku_name || 'Marketplace item',
|
||||
quantity,
|
||||
itemAmount: unit,
|
||||
totalAmount: unit * quantity,
|
||||
totalAmountWithTax: unit * quantity,
|
||||
itemType: 'product',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mapOrderShipments(tiktokOrder) {
|
||||
const packages = tiktokOrder.packages || [];
|
||||
const shippingAmount = parseFloat(tiktokOrder.payment?.shipping_fee || 0);
|
||||
const split = packages.length ? shippingAmount / packages.length : 0;
|
||||
return packages
|
||||
.filter((pkg) => pkg.id || pkg.tracking_number)
|
||||
.map((pkg) => ({
|
||||
externalReference: String(pkg.id || pkg.tracking_number),
|
||||
trackingNumber: pkg.tracking_number || '',
|
||||
amount: split,
|
||||
amountWithTax: split,
|
||||
state: { type: pkg.tracking_number ? 'shipped' : 'planned' },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function handleWebhook(marketplace, event) {
|
||||
const { type, data } = event;
|
||||
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
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 { editObject } from '../database/database.js';
|
||||
import * as tiktokShop from './marketplaces/tiktokShop.js';
|
||||
import * as ebay from './marketplaces/ebay/index.js';
|
||||
import {
|
||||
marketplaceActor,
|
||||
marketplaceSku,
|
||||
upsertExternalOrder,
|
||||
importExternalItems,
|
||||
applyWebhookAction,
|
||||
pushShipmentFulfillment,
|
||||
} from './marketplaceSync.js';
|
||||
|
||||
const logger = log4js.getLogger('Marketplace Worker');
|
||||
logger.level = config.server.logLevel;
|
||||
@ -128,8 +134,17 @@ export async function exchangeAuthorizationCode(marketplace, user, { code, state
|
||||
user
|
||||
);
|
||||
|
||||
let marketplaceWithAuth = updatedMarketplace;
|
||||
try {
|
||||
marketplaceWithAuth = await syncMarketplaceMetadata(marketplaceWithAuth, user);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Failed to sync marketplace metadata after authorization for "${marketplace.name}": ${err.message}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
marketplace: updatedMarketplace,
|
||||
marketplace: marketplaceWithAuth,
|
||||
...(authResult?.data ? { data: authResult.data } : {}),
|
||||
};
|
||||
}
|
||||
@ -156,10 +171,17 @@ export async function refreshMarketplaceAuth(marketplace, user) {
|
||||
|
||||
export function canVerifyWebhookSignature(marketplace) {
|
||||
const provider = getProvider(marketplace);
|
||||
if (typeof provider.verifyWebhookSignature !== 'function') {
|
||||
if (
|
||||
typeof provider.verifyWebhookSignature !== 'function' &&
|
||||
typeof provider.verifyNotificationSignature !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof provider.canVerifyNotificationSignature === 'function') {
|
||||
return provider.canVerifyNotificationSignature(marketplace);
|
||||
}
|
||||
|
||||
if (typeof provider.canVerifyWebhookSignature === 'function') {
|
||||
return provider.canVerifyWebhookSignature(marketplace);
|
||||
}
|
||||
@ -169,6 +191,9 @@ export function canVerifyWebhookSignature(marketplace) {
|
||||
|
||||
export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
||||
const provider = getProvider(marketplace);
|
||||
if (typeof provider.verifyNotificationSignature === 'function') {
|
||||
return provider.verifyNotificationSignature(marketplace, rawBody, signature);
|
||||
}
|
||||
if (!provider.verifyWebhookSignature) {
|
||||
logger.warn(`Provider ${marketplace.provider} does not support webhook signature verification`);
|
||||
return true;
|
||||
@ -177,9 +202,67 @@ export function verifyWebhookSignature(marketplace, rawBody, signature) {
|
||||
return provider.verifyWebhookSignature(marketplace, rawBody, signature);
|
||||
}
|
||||
|
||||
export async function handleWebhook(marketplace, event) {
|
||||
export async function handleWebhook(marketplace, event, { rawBody, signature } = {}) {
|
||||
const provider = getProvider(marketplace);
|
||||
return provider.handleWebhook(marketplace, event);
|
||||
const actor = marketplaceActor(marketplace);
|
||||
|
||||
if (signature && (await canVerifyWebhookSignature(marketplace))) {
|
||||
const valid = await verifyWebhookSignature(marketplace, rawBody || JSON.stringify(event), signature);
|
||||
if (!valid) {
|
||||
const error = new Error('Invalid webhook signature');
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const classified = await provider.handleWebhook(marketplace, event);
|
||||
return applyWebhookAction(marketplace, provider, classified, actor);
|
||||
}
|
||||
|
||||
export function buildWebhookChallengeResponse(marketplace, query) {
|
||||
const provider = getProvider(marketplace);
|
||||
if (typeof provider.buildWebhookChallengeResponse !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return provider.buildWebhookChallengeResponse(marketplace, query);
|
||||
}
|
||||
|
||||
export async function syncMarketplaceMetadata(marketplace, user) {
|
||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||
const provider = getProvider(authenticatedMarketplace);
|
||||
if (typeof provider.syncMarketplaceMetadata !== 'function') {
|
||||
return authenticatedMarketplace;
|
||||
}
|
||||
const metadataUpdates = await provider.syncMarketplaceMetadata(authenticatedMarketplace);
|
||||
if (!metadataUpdates || !Object.keys(metadataUpdates).length) {
|
||||
return authenticatedMarketplace;
|
||||
}
|
||||
return persistMarketplaceUpdate(authenticatedMarketplace, {}, metadataUpdates, user);
|
||||
}
|
||||
|
||||
export async function ensureWebhookSubscriptions(marketplace, user) {
|
||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||
const provider = getProvider(authenticatedMarketplace);
|
||||
if (typeof provider.ensureWebhookSubscriptions !== 'function') {
|
||||
return { skipped: true };
|
||||
}
|
||||
return provider.ensureWebhookSubscriptions(authenticatedMarketplace);
|
||||
}
|
||||
|
||||
export async function debugMarketplaceGet(marketplace, user, path, params = {}) {
|
||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||
const provider = getProvider(authenticatedMarketplace);
|
||||
if (typeof provider.debugGet !== 'function') {
|
||||
throw new Error(`Provider ${marketplace.provider} does not support debug GET`);
|
||||
}
|
||||
if (!String(path).startsWith('/sell/') && !String(path).startsWith('/commerce/')) {
|
||||
throw new Error('Debug proxy only allows /sell and /commerce paths');
|
||||
}
|
||||
return provider.debugGet({
|
||||
marketplace: authenticatedMarketplace,
|
||||
path,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
async function setListingState(listingId, stateType, user, message) {
|
||||
@ -262,13 +345,18 @@ export function createListing(marketplace, user, listingData) {
|
||||
if (listingData._id) {
|
||||
const updateData = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||
if (result?.url) updateData.url = result.url;
|
||||
if (result?.externalReference) updateData.externalReference = result.externalReference;
|
||||
await editObject({ model: listingModel, id: listingData._id, updateData, user });
|
||||
|
||||
for (const varient of varients) {
|
||||
const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||
if (!varient.externalReference && marketplaceSku(varient)) {
|
||||
varientUpdate.externalReference = marketplaceSku(varient);
|
||||
}
|
||||
await editObject({
|
||||
model: listingVarientModel,
|
||||
id: varient._id,
|
||||
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
|
||||
updateData: varientUpdate,
|
||||
user,
|
||||
}).catch(() => {});
|
||||
}
|
||||
@ -312,21 +400,28 @@ export function updateListing(marketplace, user, listingData) {
|
||||
logger.info(
|
||||
`Updating listing on marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||
);
|
||||
await provider.updateItem(authenticatedMarketplace, fullListing, varients);
|
||||
const result = await provider.updateItem(authenticatedMarketplace, fullListing, varients);
|
||||
|
||||
if (listingData._id) {
|
||||
const updateData = { state: { type: 'active' }, lastSyncedAt: new Date() };
|
||||
if (result?.url) updateData.url = result.url;
|
||||
if (result?.externalReference) updateData.externalReference = result.externalReference;
|
||||
await editObject({
|
||||
model: listingModel,
|
||||
id: listingData._id,
|
||||
updateData: { state: { type: 'active' }, lastSyncedAt: new Date() },
|
||||
updateData,
|
||||
user,
|
||||
});
|
||||
|
||||
for (const varient of varients) {
|
||||
const varientUpdate = { lastSyncedAt: new Date(), state: { type: 'active' } };
|
||||
if (!varient.externalReference && marketplaceSku(varient)) {
|
||||
varientUpdate.externalReference = marketplaceSku(varient);
|
||||
}
|
||||
await editObject({
|
||||
model: listingVarientModel,
|
||||
id: varient._id,
|
||||
updateData: { lastSyncedAt: new Date(), state: { type: 'active' } },
|
||||
updateData: varientUpdate,
|
||||
user,
|
||||
}).catch(() => {});
|
||||
}
|
||||
@ -359,7 +454,10 @@ export function deleteListing(marketplace, user, listingData) {
|
||||
logger.info(
|
||||
`Deleting listing from marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||
);
|
||||
await provider.deleteItem(authenticatedMarketplace, listingData);
|
||||
const varients = listingData._id
|
||||
? await fetchListingVarients(listingData._id)
|
||||
: [];
|
||||
await provider.deleteItem(authenticatedMarketplace, listingData, varients);
|
||||
logger.info(`Background deleteListing complete for marketplace "${marketplace.name}"`);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
@ -380,10 +478,29 @@ export function syncItems(marketplace, user) {
|
||||
try {
|
||||
const provider = getProvider(marketplace);
|
||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||
const actor = marketplaceActor(authenticatedMarketplace);
|
||||
logger.info(
|
||||
`Starting item sync for marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||
);
|
||||
|
||||
if (typeof provider.syncMarketplaceMetadata === 'function') {
|
||||
try {
|
||||
const metadataUpdates = await provider.syncMarketplaceMetadata(authenticatedMarketplace);
|
||||
if (metadataUpdates && Object.keys(metadataUpdates).length) {
|
||||
Object.assign(
|
||||
authenticatedMarketplace,
|
||||
await persistMarketplaceUpdate(authenticatedMarketplace, {}, metadataUpdates, user)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Failed to sync marketplace metadata for "${marketplace.name}": ${err.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await importExternalItems(authenticatedMarketplace, provider, actor);
|
||||
|
||||
const existingListings = await listingModel
|
||||
.find({
|
||||
marketplace: authenticatedMarketplace._id,
|
||||
@ -428,6 +545,9 @@ export function syncItems(marketplace, user) {
|
||||
if (result?.url) {
|
||||
listingUpdateData.url = result.url;
|
||||
}
|
||||
if (result?.externalReference) {
|
||||
listingUpdateData.externalReference = result.externalReference;
|
||||
}
|
||||
|
||||
await editObject({
|
||||
model: listingModel,
|
||||
@ -443,6 +563,9 @@ export function syncItems(marketplace, user) {
|
||||
updateData: {
|
||||
lastSyncedAt: new Date(),
|
||||
state: varient.state || { type: 'draft' },
|
||||
...(!varient.externalReference && marketplaceSku(varient)
|
||||
? { externalReference: marketplaceSku(varient) }
|
||||
: {}),
|
||||
},
|
||||
user,
|
||||
}).catch(() => {});
|
||||
@ -490,6 +613,7 @@ export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
|
||||
try {
|
||||
const provider = getProvider(marketplace);
|
||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||
const actor = marketplaceActor(authenticatedMarketplace);
|
||||
logger.info(
|
||||
`Starting order sync for marketplace "${marketplace.name}" (${marketplace.provider})`
|
||||
);
|
||||
@ -502,75 +626,24 @@ export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
|
||||
|
||||
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,
|
||||
});
|
||||
const result = await upsertExternalOrder(
|
||||
authenticatedMarketplace,
|
||||
provider,
|
||||
externalOrder,
|
||||
actor
|
||||
);
|
||||
results.push({
|
||||
externalId: mapped.externalId,
|
||||
action: 'updated',
|
||||
id: existingOrder._id,
|
||||
externalReference: result.externalReference,
|
||||
action: result.action,
|
||||
id: result.salesOrder?._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 });
|
||||
results.push({
|
||||
externalId: externalOrder.id || externalOrder.orderId,
|
||||
action: 'error',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -589,3 +662,16 @@ export function syncOrders(marketplace, user, { startTime, endTime } = {}) {
|
||||
|
||||
work();
|
||||
}
|
||||
|
||||
export async function pushMarketplaceShipmentFulfillment(marketplace, user, shipment) {
|
||||
const authenticatedMarketplace = await ensureMarketplaceAuth(marketplace, user);
|
||||
const provider = getProvider(authenticatedMarketplace);
|
||||
return pushShipmentFulfillment(
|
||||
authenticatedMarketplace,
|
||||
provider,
|
||||
shipment,
|
||||
marketplaceActor(authenticatedMarketplace)
|
||||
);
|
||||
}
|
||||
|
||||
export { marketplaceSku, marketplaceActor };
|
||||
|
||||
@ -80,6 +80,20 @@ const isAuthenticated = async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// CURL/sandbox helper: when MARKETPLACE_DEBUG_TOKEN is set, Bearer that token
|
||||
// impersonates the oldest Mongo user. Disabled unless the env var is present.
|
||||
if (process.env.MARKETPLACE_DEBUG_TOKEN && authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
if (token === process.env.MARKETPLACE_DEBUG_TOKEN) {
|
||||
const user = await userModel.findOne({}).sort({ createdAt: 1 }).lean();
|
||||
if (user) {
|
||||
req.user = { ...user, _objectType: 'user' };
|
||||
req.session = { user, debug: true };
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hostId = req.headers['x-host-id'];
|
||||
const authCode = req.headers['x-auth-code'];
|
||||
if (hostId && authCode) {
|
||||
|
||||
@ -15,6 +15,7 @@ const listAllowedFilters = [
|
||||
'deliveryTime',
|
||||
'cost',
|
||||
'costWithTax',
|
||||
'marketplaces.marketplace',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'_reference',
|
||||
@ -41,6 +42,7 @@ const propertiesAllowedFilters = [
|
||||
'deliveryTime',
|
||||
'cost',
|
||||
'costWithTax',
|
||||
'marketplaces.marketplace',
|
||||
];
|
||||
import {
|
||||
listCourierServicesRouteHandler,
|
||||
|
||||
@ -12,6 +12,7 @@ const listAllowedFilters = [
|
||||
'vendor._id',
|
||||
'stockLocation',
|
||||
'stockLocation._id',
|
||||
'stockQuantity',
|
||||
'marketplace',
|
||||
'marketplace._id',
|
||||
'courierServices',
|
||||
@ -25,6 +26,7 @@ const listAllowedSorters = [
|
||||
'title',
|
||||
'vendor',
|
||||
'state',
|
||||
'stockQuantity',
|
||||
'price',
|
||||
'lastSyncedAt',
|
||||
'createdAt',
|
||||
@ -35,6 +37,7 @@ const propertiesAllowedFilters = [
|
||||
'product',
|
||||
'vendor',
|
||||
'stockLocation',
|
||||
'stockQuantity',
|
||||
'marketplace',
|
||||
'courierServices',
|
||||
'state',
|
||||
|
||||
@ -12,18 +12,20 @@ const listAllowedFilters = [
|
||||
'product._id',
|
||||
'productSku',
|
||||
'productSku._id',
|
||||
'stockQuantity',
|
||||
'state',
|
||||
'state.type',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'_reference',
|
||||
];
|
||||
const listAllowedSorters = ['state', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'];
|
||||
const listAllowedSorters = ['state', 'stockQuantity', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'];
|
||||
const propertiesAllowedFilters = [
|
||||
'listing',
|
||||
'listing._id',
|
||||
'product',
|
||||
'productSku',
|
||||
'stockQuantity',
|
||||
'state',
|
||||
'state.type',
|
||||
'createdAt',
|
||||
|
||||
@ -26,7 +26,15 @@ const listAllowedSorters = [
|
||||
'updatedAt',
|
||||
'_id',
|
||||
];
|
||||
const propertiesAllowedFilters = ['name', 'provider', 'active', 'connected', 'state.type', 'createdAt', 'updatedAt'];
|
||||
const propertiesAllowedFilters = [
|
||||
'name',
|
||||
'provider',
|
||||
'active',
|
||||
'connected',
|
||||
'state.type',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
import {
|
||||
listMarketplacesRouteHandler,
|
||||
getMarketplaceRouteHandler,
|
||||
@ -39,22 +47,39 @@ import {
|
||||
getMarketplaceAuthUrlRouteHandler,
|
||||
exchangeMarketplaceAuthCodeRouteHandler,
|
||||
refreshMarketplaceAuthRouteHandler,
|
||||
syncMarketplaceRouteHandler,
|
||||
syncMarketplaceItemsRouteHandler,
|
||||
syncMarketplaceOrdersRouteHandler,
|
||||
marketplaceWebhookRouteHandler,
|
||||
marketplaceWebhookChallengeRouteHandler,
|
||||
subscribeMarketplaceWebhooksRouteHandler,
|
||||
debugEbayRouteHandler,
|
||||
searchMarketplacesRouteHandler,
|
||||
getMarketplacePropertyValuesRouteHandler,
|
||||
|
||||
getMarketplaceNeighborsRouteHandler
|
||||
getMarketplaceNeighborsRouteHandler,
|
||||
} from '../../services/sales/marketplaces.js';
|
||||
|
||||
router.get('/', isAuthenticated, async (req, res) => {
|
||||
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
|
||||
const filter = await getFilter(req.query, listAllowedFilters);
|
||||
listMarketplacesRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder);
|
||||
listMarketplacesRouteHandler(
|
||||
req,
|
||||
res,
|
||||
page,
|
||||
limit,
|
||||
property,
|
||||
filter,
|
||||
search,
|
||||
getSort(sortProperty, listAllowedSorters),
|
||||
sortOrder
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/properties', checkPermissions('marketplace', 'list'), isAuthenticated, async (req, res) => {
|
||||
router.get(
|
||||
'/properties',
|
||||
checkPermissions('marketplace', 'list'),
|
||||
isAuthenticated,
|
||||
async (req, res) => {
|
||||
let properties = convertPropertiesString(req.query.properties);
|
||||
const filter = await getFilter(req.query, propertiesAllowedFilters, false);
|
||||
var masterFilter = {};
|
||||
@ -62,17 +87,27 @@ router.get('/properties', checkPermissions('marketplace', 'list'), isAuthenticat
|
||||
masterFilter = JSON.parse(req.query.masterFilter);
|
||||
}
|
||||
listMarketplacesByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/values', checkPermissions('marketplace', 'list'), isAuthenticated, async (req, res) => {
|
||||
router.get(
|
||||
'/values',
|
||||
checkPermissions('marketplace', 'list'),
|
||||
isAuthenticated,
|
||||
async (req, res) => {
|
||||
const { property } = req.query;
|
||||
getMarketplacePropertyValuesRouteHandler(req, res, property);
|
||||
});
|
||||
router.get('/search', checkPermissions('marketplace', 'list'), isAuthenticated, async (req, res) => {
|
||||
}
|
||||
);
|
||||
router.get(
|
||||
'/search',
|
||||
checkPermissions('marketplace', 'list'),
|
||||
isAuthenticated,
|
||||
async (req, res) => {
|
||||
const { search } = req.query;
|
||||
searchMarketplacesRouteHandler(req, res, search);
|
||||
});
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/', isAuthenticated, checkPermissions('marketplace', 'new'), async (req, res) => {
|
||||
newMarketplaceRouteHandler(req, res);
|
||||
@ -90,7 +125,10 @@ router.get('/:id/auth/url', isAuthenticated, async (req, res) => {
|
||||
getMarketplaceAuthUrlRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.post('/:id/auth/exchange', isAuthenticated, async (req, res, next) => {
|
||||
router.post(
|
||||
'/:id/auth/exchange',
|
||||
isAuthenticated,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const allowed =
|
||||
(await hasPermission(req.user, 'marketplace', 'connect')) ||
|
||||
@ -102,31 +140,92 @@ router.post('/:id/auth/exchange', isAuthenticated, async (req, res, next) => {
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}, async (req, res) => {
|
||||
},
|
||||
async (req, res) => {
|
||||
exchangeMarketplaceAuthCodeRouteHandler(req, res);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/:id/auth/refresh', isAuthenticated, checkPermissions('marketplace', 'refreshToken'), async (req, res) => {
|
||||
router.post(
|
||||
'/:id/auth/refresh',
|
||||
isAuthenticated,
|
||||
checkPermissions('marketplace', 'refreshToken'),
|
||||
async (req, res) => {
|
||||
refreshMarketplaceAuthRouteHandler(req, res);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/:id/sync/items', isAuthenticated, checkPermissions('marketplace', 'syncListings'), async (req, res) => {
|
||||
router.post(
|
||||
'/:id/sync',
|
||||
isAuthenticated,
|
||||
checkPermissions('marketplace', 'sync'),
|
||||
async (req, res) => {
|
||||
syncMarketplaceRouteHandler(req, res);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/sync/items',
|
||||
isAuthenticated,
|
||||
checkPermissions('marketplace', 'sync'),
|
||||
async (req, res) => {
|
||||
syncMarketplaceItemsRouteHandler(req, res);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/:id/sync/orders', isAuthenticated, checkPermissions('marketplace', 'syncOrders'), async (req, res) => {
|
||||
router.post(
|
||||
'/:id/sync/orders',
|
||||
isAuthenticated,
|
||||
checkPermissions('marketplace', 'sync'),
|
||||
async (req, res) => {
|
||||
syncMarketplaceOrdersRouteHandler(req, res);
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/:id/hook', async (req, res) => {
|
||||
marketplaceWebhookChallengeRouteHandler(req, res);
|
||||
});
|
||||
router.get('/:id/ebay-challenge', async (req, res) => {
|
||||
marketplaceWebhookChallengeRouteHandler(req, res);
|
||||
});
|
||||
|
||||
// Webhook endpoint — no auth, provider verifies via signature
|
||||
router.post('/:id/hook', async (req, res) => {
|
||||
marketplaceWebhookRouteHandler(req, res);
|
||||
});
|
||||
router.post('/:id/ebay-webhook', async (req, res) => {
|
||||
marketplaceWebhookRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/:id/webhooks/subscribe',
|
||||
isAuthenticated,
|
||||
checkPermissions('marketplace', 'connect'),
|
||||
async (req, res) => {
|
||||
subscribeMarketplaceWebhooksRouteHandler(req, res);
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/:id/debug-ebay', isAuthenticated, async (req, res) => {
|
||||
debugEbayRouteHandler(req, res);
|
||||
});
|
||||
router.get('/:id/debug/ebay', isAuthenticated, async (req, res) => {
|
||||
debugEbayRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.get('/neighbors', isAuthenticated, async (req, res) => {
|
||||
const { property, search, sortProperty, sortOrder, id } = req.query;
|
||||
const filter = await getFilter(req.query, listAllowedFilters);
|
||||
getMarketplaceNeighborsRouteHandler(req, res, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder, id);
|
||||
getMarketplaceNeighborsRouteHandler(
|
||||
req,
|
||||
res,
|
||||
property,
|
||||
filter,
|
||||
search,
|
||||
getSort(sortProperty, listAllowedSorters),
|
||||
sortOrder,
|
||||
id
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/:id', isAuthenticated, checkPermissions('marketplace', 'info'), async (req, res) => {
|
||||
|
||||
@ -20,6 +20,9 @@ import {
|
||||
const logger = log4js.getLogger('Shipments');
|
||||
logger.level = config.server.logLevel;
|
||||
import { orderItemModel } from '../../database/schemas/inventory/orderitem.schema.js';
|
||||
import { salesOrderModel } from '../../database/schemas/sales/salesorder.schema.js';
|
||||
import { marketplaceModel } from '../../database/schemas/sales/marketplace.schema.js';
|
||||
import * as marketplaceIntegration from '../../integrations/marketplaceworker.js';
|
||||
|
||||
export const listShipmentsRouteHandler = async (
|
||||
req,
|
||||
@ -316,6 +319,30 @@ export const shipShipmentRouteHandler = async (req, res) => {
|
||||
}
|
||||
logger.debug(`Shipped shipment with ID: ${id}`);
|
||||
res.send(result);
|
||||
|
||||
const orderId = result.order?._id || result.order;
|
||||
if (result.orderType === 'salesOrder' && orderId) {
|
||||
salesOrderModel
|
||||
.findById(orderId)
|
||||
.lean()
|
||||
.then(async (salesOrder) => {
|
||||
if (!salesOrder?.marketplace) return;
|
||||
const marketplace = await marketplaceModel.findById(salesOrder.marketplace);
|
||||
if (!marketplace) return;
|
||||
const shipment = await shipmentModel
|
||||
.findById(id)
|
||||
.populate('courierService')
|
||||
.lean();
|
||||
await marketplaceIntegration.pushMarketplaceShipmentFulfillment(
|
||||
marketplace,
|
||||
req.user,
|
||||
shipment
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`Failed to push marketplace fulfillment for shipment ${id}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
@ -18,6 +18,8 @@ import {
|
||||
const logger = log4js.getLogger('CourierServices');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
const COURIER_SERVICE_POPULATE = ['courier', 'costTaxRate', 'marketplaces.marketplace'];
|
||||
|
||||
export const listCourierServicesRouteHandler = async (
|
||||
req,
|
||||
res,
|
||||
@ -38,7 +40,7 @@ export const listCourierServicesRouteHandler = async (
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
populate: ['courier', 'costTaxRate'],
|
||||
populate: COURIER_SERVICE_POPULATE,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
@ -63,7 +65,7 @@ export const listCourierServicesByPropertiesRouteHandler = async (
|
||||
properties,
|
||||
filter,
|
||||
masterFilter,
|
||||
populate: ['courier', 'costTaxRate'],
|
||||
populate: COURIER_SERVICE_POPULATE,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
@ -88,6 +90,7 @@ export const searchCourierServicesRouteHandler = async (req, res, search) => {
|
||||
const result = await searchObjects({
|
||||
model: courierServiceModel,
|
||||
search,
|
||||
populate: COURIER_SERVICE_POPULATE,
|
||||
});
|
||||
res.send(result);
|
||||
};
|
||||
@ -97,7 +100,7 @@ export const getCourierServiceRouteHandler = async (req, res) => {
|
||||
const result = await getObject({
|
||||
model: courierServiceModel,
|
||||
id,
|
||||
populate: ['courier', 'costTaxRate'],
|
||||
populate: COURIER_SERVICE_POPULATE,
|
||||
});
|
||||
if (result?.error) {
|
||||
logger.warn(`Courier service not found with supplied id.`);
|
||||
@ -128,14 +131,14 @@ export const editCourierServiceRouteHandler = async (req, res) => {
|
||||
additionalCostWithTax: req.body?.additionalCostWithTax,
|
||||
shippingCurrency: req.body?.shippingCurrency,
|
||||
international: req.body?.international,
|
||||
marketplaces: req.body?.marketplaces,
|
||||
};
|
||||
// Create audit log before updating
|
||||
const result = await editObject({
|
||||
model: courierServiceModel,
|
||||
id,
|
||||
updateData,
|
||||
user: req.user,
|
||||
populate: ['courier', 'costTaxRate'],
|
||||
populate: COURIER_SERVICE_POPULATE,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@ -166,6 +169,7 @@ export const newCourierServiceRouteHandler = async (req, res) => {
|
||||
additionalCostWithTax: req.body?.additionalCostWithTax,
|
||||
shippingCurrency: req.body?.shippingCurrency,
|
||||
international: req.body?.international,
|
||||
marketplaces: req.body?.marketplaces,
|
||||
};
|
||||
const result = await newObject({
|
||||
model: courierServiceModel,
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
deleteListing as deleteExternalListing,
|
||||
publishMarketplaceOfferForSku,
|
||||
withdrawMarketplaceOfferForSku,
|
||||
marketplaceSku,
|
||||
} from '../../integrations/marketplaceworker.js';
|
||||
|
||||
const logger = log4js.getLogger('Listings');
|
||||
@ -159,7 +160,9 @@ export const editListingRouteHandler = async (req, res) => {
|
||||
stockLocation: req.body.stockLocation,
|
||||
marketplace: req.body.marketplace,
|
||||
title: req.body.title,
|
||||
description: req.body.description,
|
||||
url: req.body.url,
|
||||
condition: req.body.condition,
|
||||
courierServices: req.body.courierServices,
|
||||
};
|
||||
const result = await editObject({
|
||||
@ -193,8 +196,10 @@ export const newListingRouteHandler = async (req, res) => {
|
||||
stockLocation: req.body.stockLocation,
|
||||
marketplace: req.body.marketplace,
|
||||
title: req.body.title,
|
||||
description: req.body.description,
|
||||
state: req.body.state || { type: 'draft' },
|
||||
url: req.body.url,
|
||||
condition: req.body.condition,
|
||||
courierServices: req.body.courierServices,
|
||||
};
|
||||
const result = await newObject({
|
||||
@ -370,7 +375,7 @@ export const publishListingRouteHandler = async (req, res) => {
|
||||
const apiResult = await publishMarketplaceOfferForSku(
|
||||
marketplace,
|
||||
req.user,
|
||||
v._reference,
|
||||
marketplaceSku(v),
|
||||
listing
|
||||
);
|
||||
await editObject({
|
||||
@ -464,7 +469,7 @@ export const unpublishListingRouteHandler = async (req, res) => {
|
||||
|
||||
try {
|
||||
for (const v of toUnpublish) {
|
||||
await withdrawMarketplaceOfferForSku(marketplace, req.user, v._reference);
|
||||
await withdrawMarketplaceOfferForSku(marketplace, req.user, marketplaceSku(v));
|
||||
await editObject({
|
||||
model: listingVarientModel,
|
||||
id: v._id,
|
||||
@ -531,4 +536,3 @@ export const getListingNeighborsRouteHandler = async (
|
||||
logger.debug(`Retrieved listing neighbors for ID: ${id}`);
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
hasIntegration,
|
||||
publishMarketplaceOfferForSku,
|
||||
withdrawMarketplaceOfferForSku,
|
||||
marketplaceSku,
|
||||
} from '../../integrations/marketplaceworker.js';
|
||||
|
||||
const logger = log4js.getLogger('ListingVarients');
|
||||
@ -306,7 +307,7 @@ export const publishListingVarientRouteHandler = async (req, res) => {
|
||||
const apiResult = await publishMarketplaceOfferForSku(
|
||||
marketplace,
|
||||
req.user,
|
||||
doc._reference,
|
||||
marketplaceSku(doc),
|
||||
doc.listing
|
||||
);
|
||||
|
||||
@ -415,7 +416,7 @@ export const unpublishListingVarientRouteHandler = async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await withdrawMarketplaceOfferForSku(marketplace, req.user, doc._reference);
|
||||
await withdrawMarketplaceOfferForSku(marketplace, req.user, marketplaceSku(doc));
|
||||
|
||||
await editObject({
|
||||
model: listingVarientModel,
|
||||
|
||||
@ -257,6 +257,9 @@ export const exchangeMarketplaceAuthCodeRouteHandler = async (req, res) => {
|
||||
});
|
||||
|
||||
logger.info(`Marketplace authorization completed for ${marketplace.name}`);
|
||||
marketplaceIntegration.ensureWebhookSubscriptions(result.marketplace || marketplace, req.user).catch((err) => {
|
||||
logger.warn(`Failed to subscribe marketplace webhooks for ${marketplace.name}: ${err.message}`);
|
||||
});
|
||||
res.send({ success: true, ...result });
|
||||
} catch (err) {
|
||||
logger.error('Error exchanging marketplace authorization code:', err.message);
|
||||
@ -289,6 +292,39 @@ export const refreshMarketplaceAuthRouteHandler = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const syncMarketplaceRouteHandler = 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,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const syncedMarketplace = await marketplaceIntegration.syncMarketplaceMetadata(
|
||||
marketplace,
|
||||
req.user
|
||||
);
|
||||
logger.info(`Marketplace metadata synced for ${marketplace.name}`);
|
||||
res.send({ success: true, marketplace: syncedMarketplace });
|
||||
} catch (err) {
|
||||
logger.error(`Error syncing marketplace ${marketplace.name}:`, err.message);
|
||||
res.status(400).send({ error: err.message, code: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export const syncMarketplaceItemsRouteHandler = async (req, res) => {
|
||||
const id = req.params.id;
|
||||
|
||||
@ -361,27 +397,88 @@ export const marketplaceWebhookRouteHandler = async (req, res) => {
|
||||
}
|
||||
|
||||
const signature =
|
||||
req.headers['x-tts-signature'] ||
|
||||
req.headers['x-ebay-signature'] ||
|
||||
req.headers['x-tts-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 });
|
||||
}
|
||||
}
|
||||
const rawBody = req.rawBody || JSON.stringify(req.body);
|
||||
|
||||
try {
|
||||
const result = await marketplaceIntegration.handleWebhook(marketplace, req.body);
|
||||
const result = await marketplaceIntegration.handleWebhook(marketplace, req.body, {
|
||||
rawBody,
|
||||
signature,
|
||||
});
|
||||
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 });
|
||||
res.status(err.status || 500).send({ error: err.message, code: err.status || 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const marketplaceWebhookChallengeRouteHandler = async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||
if (marketplace?.error) {
|
||||
return res.status(404).send({ error: 'Marketplace not found.', code: 404 });
|
||||
}
|
||||
|
||||
const challengeCode = req.query.challenge_code || req.query.challengeCode;
|
||||
const endpoint =
|
||||
marketplace.config?.webhookUrl ||
|
||||
`${req.protocol}://${req.get('host')}/marketplaces/${id}/hook`;
|
||||
|
||||
try {
|
||||
const result = marketplaceIntegration.buildWebhookChallengeResponse(marketplace, {
|
||||
challengeCode,
|
||||
endpoint,
|
||||
});
|
||||
if (!result) {
|
||||
return res.status(400).send({ error: 'Provider does not support webhook challenges.', code: 400 });
|
||||
}
|
||||
res.send(result);
|
||||
} catch (err) {
|
||||
logger.error('Error handling webhook challenge:', err.message);
|
||||
res.status(400).send({ error: err.message, code: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeMarketplaceWebhooksRouteHandler = async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||
if (marketplace?.error) {
|
||||
return res.status(marketplace.code).send(marketplace);
|
||||
}
|
||||
try {
|
||||
const result = await marketplaceIntegration.ensureWebhookSubscriptions(marketplace, req.user);
|
||||
res.send({ success: true, ...result });
|
||||
} catch (err) {
|
||||
logger.error('Error subscribing marketplace webhooks:', err.message);
|
||||
res.status(400).send({ error: err.message, code: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export const debugEbayRouteHandler = async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const marketplace = await getObject({ model: marketplaceModel, id });
|
||||
if (marketplace?.error) {
|
||||
return res.status(marketplace.code).send(marketplace);
|
||||
}
|
||||
if (marketplace.provider !== 'ebay') {
|
||||
return res.status(400).send({ error: 'Debug eBay proxy is only available for eBay marketplaces.', code: 400 });
|
||||
}
|
||||
if (marketplace.config?.sandbox !== true) {
|
||||
return res.status(400).send({ error: 'eBay debug proxy is sandbox-only.', code: 400 });
|
||||
}
|
||||
try {
|
||||
const path = req.query.path || '/sell/inventory/v1/inventory_item';
|
||||
const data = await marketplaceIntegration.debugMarketplaceGet(marketplace, req.user, path, {
|
||||
limit: req.query.limit || 1,
|
||||
});
|
||||
res.send({ success: true, data });
|
||||
} catch (err) {
|
||||
logger.error('eBay debug GET failed:', err.message);
|
||||
res.status(400).send({ error: err.message, code: 400 });
|
||||
}
|
||||
};
|
||||
export const getMarketplaceNeighborsRouteHandler = async (
|
||||
|
||||
36
src/utils.js
36
src/utils.js
@ -16,6 +16,7 @@ import { Worker } from 'worker_threads';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { diffJson } from 'diff';
|
||||
import { resolveAuditOwner, actorDisplayName } from './auditOwner.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@ -1064,7 +1065,7 @@ function getChangedValues(oldObj, newObj, old = false) {
|
||||
return changes;
|
||||
}
|
||||
|
||||
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier'];
|
||||
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier', 'marketplaceEvent'];
|
||||
const SENSITIVE_KEYS = ['secret'];
|
||||
|
||||
const DISTRIBUTE_KEYS = {
|
||||
@ -1101,8 +1102,7 @@ async function newAuditLog(newValue, parentId, parentType, user) {
|
||||
},
|
||||
parent: parentId,
|
||||
parentType,
|
||||
owner: user._id,
|
||||
ownerType: 'user',
|
||||
...resolveAuditOwner(user),
|
||||
operation: 'new',
|
||||
});
|
||||
|
||||
@ -1130,8 +1130,7 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
|
||||
},
|
||||
parent: parentId,
|
||||
parentType,
|
||||
owner: user._id,
|
||||
ownerType: 'user',
|
||||
...resolveAuditOwner(user),
|
||||
operation: 'edit',
|
||||
});
|
||||
|
||||
@ -1153,7 +1152,7 @@ async function editNotification(oldValue, newValue, parentId, parentType, user)
|
||||
await notfiyObjectUserNotifiers(
|
||||
parentId,
|
||||
parentType,
|
||||
`${objectName} edited by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
||||
`${objectName} edited by ${actorDisplayName(user)}`,
|
||||
`The ${parentType} ${parentId} has been updated.`,
|
||||
'editObject',
|
||||
{
|
||||
@ -1163,8 +1162,8 @@ async function editNotification(oldValue, newValue, parentId, parentType, user)
|
||||
object: { _id: String(parentId ?? '') },
|
||||
user: {
|
||||
_id: String(user?._id ?? ''),
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
firstName: user?.firstName ?? user?.name ?? '',
|
||||
lastName: user?.lastName ?? '',
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -1179,8 +1178,7 @@ async function deleteAuditLog(deleteValue, parentId, parentType, user) {
|
||||
},
|
||||
parent: parentId,
|
||||
parentType,
|
||||
owner: user._id,
|
||||
ownerType: 'user',
|
||||
...resolveAuditOwner(user),
|
||||
operation: 'delete',
|
||||
});
|
||||
|
||||
@ -1195,14 +1193,18 @@ async function deleteNotification(object, parentId, parentType, user) {
|
||||
await notfiyObjectUserNotifiers(
|
||||
parentId,
|
||||
parentType,
|
||||
`${objectName} deleted by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
||||
`${objectName} deleted by ${actorDisplayName(user)}`,
|
||||
`The ${parentType} ${parentId} has been deleted.`,
|
||||
'deleteObject',
|
||||
{
|
||||
object: omitSensitive(object),
|
||||
objectType: parentType,
|
||||
object: { _id: parentId },
|
||||
user: { _id: user._id, firstName: user.firstName, lastName: user.lastName },
|
||||
user: {
|
||||
_id: user?._id,
|
||||
firstName: user?.firstName ?? user?.name ?? '',
|
||||
lastName: user?.lastName ?? '',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -1213,7 +1215,7 @@ async function newNoteNotification(note, user) {
|
||||
await notfiyObjectUserNotifiers(
|
||||
note.parent,
|
||||
note.parentType,
|
||||
`New note added to ${objectName.toLowerCase()} by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
||||
`New note added to ${objectName.toLowerCase()} by ${actorDisplayName(user)}`,
|
||||
`A new note has been created.`,
|
||||
'newNote',
|
||||
{
|
||||
@ -1233,8 +1235,7 @@ async function subscribeAuditLog(parentId, parentType, user) {
|
||||
const auditLog = new auditLogModel({
|
||||
parent: parentId,
|
||||
parentType,
|
||||
owner: user._id,
|
||||
ownerType: 'user',
|
||||
...resolveAuditOwner(user),
|
||||
operation: 'subscribe',
|
||||
});
|
||||
await auditLog.save();
|
||||
@ -1246,8 +1247,7 @@ async function unsubscribeAuditLog(parentId, parentType, user) {
|
||||
const auditLog = new auditLogModel({
|
||||
parent: parentId,
|
||||
parentType,
|
||||
owner: user._id,
|
||||
ownerType: 'user',
|
||||
...resolveAuditOwner(user),
|
||||
operation: 'unsubscribe',
|
||||
});
|
||||
await auditLog.save();
|
||||
@ -1811,4 +1811,6 @@ export {
|
||||
jsonToCacheKey,
|
||||
subscribeAuditLog,
|
||||
unsubscribeAuditLog,
|
||||
resolveAuditOwner,
|
||||
actorDisplayName,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user