farmcontrol-api/src/integrations/marketplaceSync.js
Tom Butcher 5ff93be27c
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Add audit owner resolution and actor display name functions
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.
2026-08-24 02:49:38 +01:00

483 lines
15 KiB
JavaScript

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 };