3 Commits
3 changed files with 213 additions and 18 deletions
@@ -16,7 +16,7 @@ export const shopifyMapper = {
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const uniqueSuffix = String(identity.id).replace(/[^a-z0-9]/gi, '').slice(0, 10);
const uniqueSuffix = String(identity.id).replace(/[^a-z0-9]/gi, '').slice(0, 8) + '-' + Date.now().toString(36);
input.handle = `${baseSlug}-${uniqueSuffix}`;
}
@@ -30,7 +30,16 @@ export const shopifyMapper = {
});
if (optionKeys.size > 0) {
input.productOptions = Array.from(optionKeys).slice(0, 3).map(optName => ({
const priorityOrder = ['color', 'size', 'storage', 'ram', 'rom', 'material', 'style'];
const top3OptionKeys = Array.from(optionKeys).sort((a, b) => {
const idxA = priorityOrder.indexOf(a.toLowerCase());
const idxB = priorityOrder.indexOf(b.toLowerCase());
const pA = idxA === -1 ? 99 : idxA;
const pB = idxB === -1 ? 99 : idxB;
return pA - pB;
}).slice(0, 3);
input.productOptions = top3OptionKeys.map(optName => ({
name: optName,
values: Array.from(new Set(
variants
@@ -9,6 +9,16 @@ const PRODUCT_CREATE_MUTATION = `
title
handle
onlineStoreUrl
variants(first: 50) {
nodes {
id
title
selectedOptions {
name
value
}
}
}
}
userErrors {
field
@@ -26,6 +36,16 @@ const PRODUCT_UPDATE_MUTATION = `
title
handle
onlineStoreUrl
variants(first: 50) {
nodes {
id
title
selectedOptions {
name
value
}
}
}
}
userErrors {
field
@@ -62,6 +82,38 @@ const PRODUCT_CREATE_MEDIA_MUTATION = `
}
`;
const BULK_VARIANTS_CREATE_MUTATION = `
mutation productVariantsBulkCreate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $productId, variants: $variants) {
productVariants {
id
title
price
}
userErrors {
field
message
}
}
}
`;
const BULK_VARIANTS_UPDATE_MUTATION = `
mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants {
id
title
price
}
userErrors {
field
message
}
}
}
`;
export class ShopifyPublisher {
constructor(shopifyClient) {
this.client = shopifyClient;
@@ -107,14 +159,119 @@ export class ShopifyPublisher {
throw new Error('Shopify GraphQL returned empty product response');
}
// Attach media/image URLs to Shopify product
const validMedia = (canonicalProduct.media || [])
.filter(m => m.url && (m.url.startsWith('http://') || m.url.startsWith('https://')))
.map(m => ({
mediaContentType: 'IMAGE',
originalSource: m.url,
alt: canonicalProduct.content.name
}));
// ── Multi-Variant Bulk Creation & Synchronization ──
const pimVariants = canonicalProduct.variants || [];
if (Array.isArray(pimVariants) && pimVariants.length > 0) {
const existingNodes = shopifyProduct.variants?.nodes || [];
// Allowed option names in Shopify product
const allowedOptionNames = (input.productOptions || []).map(o => o.name);
const newVariantsInput = [];
const updateVariantsInput = [];
for (const v of pimVariants) {
let optionValues = Object.entries(v.attributes || {}).map(([optName, optVal]) => ({
optionName: optName,
name: String(optVal)
}));
if (allowedOptionNames.length > 0) {
optionValues = optionValues.filter(ov =>
allowedOptionNames.some(aName => aName.toLowerCase() === ov.optionName.toLowerCase())
);
}
if (optionValues.length === 0) continue;
// Try matching with existing Shopify variant node
const matchedNode = existingNodes.find(node => {
const nodeOptions = node.selectedOptions || [];
return optionValues.every(ov =>
nodeOptions.some(no => no.name.toLowerCase() === ov.optionName.toLowerCase() && no.value.toLowerCase() === ov.name.toLowerCase())
);
});
if (matchedNode) {
updateVariantsInput.push({
id: matchedNode.id,
price: String(v.price || 0),
compareAtPrice: v.costPrice ? String(v.costPrice) : null,
inventoryItem: {
sku: v.sku || '',
tracked: true
}
});
} else {
newVariantsInput.push({
optionValues,
price: String(v.price || 0),
compareAtPrice: v.costPrice ? String(v.costPrice) : null,
inventoryItem: {
sku: v.sku || '',
tracked: true
}
});
}
}
// Create new variants
if (newVariantsInput.length > 0) {
try {
const createRes = await this.client.graphql(BULK_VARIANTS_CREATE_MUTATION, {
productId: shopifyProduct.id,
variants: newVariantsInput
});
const createErrors = createRes.data?.productVariantsBulkCreate?.userErrors;
if (createErrors?.length > 0) {
console.warn('[ShopifyPublisher] Variant create warnings:', createErrors.map(e => e.message).join(', '));
}
} catch (vErr) {
console.warn('[ShopifyPublisher] Variant bulk create error:', vErr.message);
}
}
// Update existing variants
if (updateVariantsInput.length > 0) {
try {
const updateRes = await this.client.graphql(BULK_VARIANTS_UPDATE_MUTATION, {
productId: shopifyProduct.id,
variants: updateVariantsInput
});
const updateErrors = updateRes.data?.productVariantsBulkUpdate?.userErrors;
if (updateErrors?.length > 0) {
console.warn('[ShopifyPublisher] Variant update warnings:', updateErrors.map(e => e.message).join(', '));
}
} catch (vErr) {
console.warn('[ShopifyPublisher] Variant bulk update error:', vErr.message);
}
}
}
// ── Attach Media/Images to Shopify Product ──
const allMediaUrls = new Set();
// Product level media
(canonicalProduct.media || []).forEach(m => {
if (m.url && (m.url.startsWith('http://') || m.url.startsWith('https://'))) {
allMediaUrls.add(m.url);
}
});
// Variant level media
(pimVariants).forEach(v => {
(v.media || []).forEach(m => {
if (m.url && (m.url.startsWith('http://') || m.url.startsWith('https://'))) {
allMediaUrls.add(m.url);
}
});
});
const validMedia = Array.from(allMediaUrls).map(url => ({
mediaContentType: 'IMAGE',
originalSource: url,
alt: canonicalProduct.content.name
}));
if (validMedia.length > 0 && shopifyProduct?.id) {
try {
@@ -158,7 +315,8 @@ export class ShopifyPublisher {
}
async deleteProduct(context) {
const { tenantId, integrationId, productId } = context;
const { tenantId, integrationId, canonicalProduct } = context;
const productId = canonicalProduct.identity.id;
const existingResource = await models.ExternalResource.findOne({
where: {
@@ -170,19 +328,24 @@ export class ShopifyPublisher {
});
if (!existingResource) {
return { success: true, message: 'Resource was not published to Shopify' };
return { success: true, message: 'No external mapping found for deletion' };
}
const response = await this.client.graphql(PRODUCT_DELETE_MUTATION, {
input: { id: existingResource.external_id }
});
const result = response.data?.productDelete;
if (result?.userErrors?.length > 0) {
throw new Error(`Shopify productDelete user errors: ${result.userErrors.map(e => e.message).join(', ')}`);
const userErrors = response.data?.productDelete?.userErrors;
if (userErrors?.length > 0) {
throw new Error(`Shopify productDelete user errors: ${userErrors.map(e => e.message).join(', ')}`);
}
await existingResource.destroy();
return { success: true, deletedProductId: result.deletedProductId };
return {
success: true,
externalId: existingResource.external_id,
operation: 'DELETE'
};
}
}
@@ -80,8 +80,31 @@ export const canonicalProductBuilder = {
const vAttrs = {};
if (Array.isArray(v.values)) {
v.values.forEach(val => {
const key = val.axis?.code || val.attribute_id;
vAttrs[key] = val.value_text || val.value_number;
const rawKey = val.axis?.name || val.axis?.code || '';
let key = rawKey
.replace(/_variant$/i, '')
.replace(/_/g, ' ')
.trim()
.replace(/\b\w/g, l => l.toUpperCase());
// Normalize common option names
if (/^colors?$/i.test(key)) key = 'Color';
if (/^sizes?$/i.test(key)) key = 'Size';
if (/^ram$/i.test(key)) key = 'RAM';
// Skip brand / vendor from variant options
if (!key || /^brand$/i.test(key) || /^vendor$/i.test(key) || key.toLowerCase().includes('brand_id')) {
return;
}
let valStr = String(val.value_text || val.value_number || '').trim();
// Skip if value looks like a UUID
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(valStr)) {
return;
}
if (valStr.length > 0) {
vAttrs[key] = valStr.length <= 3 ? valStr.toUpperCase() : valStr.charAt(0).toUpperCase() + valStr.slice(1);
}
});
}