Logs every step for each product: - Job request received (shop, brand, selected IDs) - Shopify token lookup (token, scope, locationId, fulfillmentService) - Turn14 fetch (URL, HTTP status, item count, response time) - Per-product: all attributes, inventory, fitment tags - Collections: each title looked up/created with IDs - Dedup check: handle existence - productCreate: title, vendor, collections, media count - Pricing: metafield config, base price, final price, markup - Variant update: SKU, price, weight - Publish: available publications, Online Store result - Inventory: cost, activate at location, quantity set - SEO: title and description - Job completion summary: created/skipped/failed counts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
759 lines
38 KiB
JavaScript
Executable File
759 lines
38 KiB
JavaScript
Executable File
// routes/manageProducts.js
|
||
const express = require('express');
|
||
const axios = require('axios');
|
||
const { v4: uuid } = require('uuid');
|
||
const { getToken } = require('../tokenStore');
|
||
const { log } = require('../logger');
|
||
const crypto = require('crypto');
|
||
const router = express.Router();
|
||
|
||
const {
|
||
createJob,
|
||
updateJob,
|
||
appendJobLog,
|
||
recordProductResult,
|
||
recordProductError,
|
||
finishJob,
|
||
cancelJob,
|
||
isJobCancelled,
|
||
getJob,
|
||
listJobs,
|
||
} = require('../jobStore');
|
||
|
||
const seo_llm_client = axios.create({
|
||
baseURL: 'https://llm.thedomainnest.com',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
timeout: 0,
|
||
});
|
||
|
||
function slugify(str) {
|
||
return str
|
||
.toString()
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
}
|
||
|
||
function extractFirstJsonObject(text) {
|
||
if (typeof text !== 'string') return text;
|
||
let s = text.trim()
|
||
.replace(/^```json\s*/i, '')
|
||
.replace(/^```\s*/i, '')
|
||
.replace(/```$/i, '')
|
||
.trim();
|
||
const start = s.indexOf('{');
|
||
const end = s.lastIndexOf('}');
|
||
if (start === -1 || end === -1 || end <= start) return null;
|
||
s = s.slice(start, end + 1);
|
||
s = s.replace(/(\{|,)\s*(seo_title|seo_description)\s*:/g, '$1"$2":');
|
||
s = s.replace(/"seo_description\s*:\s*/g, '"seo_description":');
|
||
return s;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fetch all products for a brand from Turn14
|
||
// ---------------------------------------------------------------------------
|
||
const GetAllProductsOfBranch = async (brandId, turn14accessToken, shop, jobId) => {
|
||
console.log(`\n📦 [TURN14-FETCH] ========================================`);
|
||
console.log(`📦 [TURN14-FETCH] Fetching products for brand ID: ${brandId}`);
|
||
console.log(`📦 [TURN14-FETCH] Shop: ${shop}`);
|
||
console.log(`📦 [TURN14-FETCH] Job ID: ${jobId}`);
|
||
console.log(`📦 [TURN14-FETCH] Token (first 20 chars): ${turn14accessToken?.slice(0, 20)}...`);
|
||
console.log(`📦 [TURN14-FETCH] URL: https://turn14.data4autos.com/v1/items/brandallitemswithfitment/${brandId}`);
|
||
|
||
try {
|
||
appendJobLog(jobId, `[FETCH] Fetching products for brand ${brandId} from Turn14...`);
|
||
const fetchStart = Date.now();
|
||
const res = await fetch(
|
||
`https://turn14.data4autos.com/v1/items/brandallitemswithfitment/${brandId}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${turn14accessToken}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
}
|
||
);
|
||
|
||
console.log(`📦 [TURN14-FETCH] HTTP status: ${res.status} ${res.statusText}`);
|
||
console.log(`📦 [TURN14-FETCH] Response time: ${Date.now() - fetchStart}ms`);
|
||
|
||
if (!res.ok) {
|
||
console.error(`❌ [TURN14-FETCH] Non-200 response: ${res.status}`);
|
||
}
|
||
|
||
const res_data = await res.json();
|
||
const data = res_data.items || [];
|
||
const fitmentTags = res_data.fitmentTags || [];
|
||
|
||
console.log(`📦 [TURN14-FETCH] Raw items in response: ${data.length}`);
|
||
console.log(`📦 [TURN14-FETCH] FitmentTags count: ${fitmentTags.length}`);
|
||
|
||
const validItems = Array.isArray(data)
|
||
? data.filter(item => item && item.id && item.attributes)
|
||
: [];
|
||
|
||
console.log(`📦 [TURN14-FETCH] Valid items (have id + attributes): ${validItems.length}`);
|
||
console.log(`📦 [TURN14-FETCH] Sample item IDs: ${validItems.slice(0, 5).map(i => i.id).join(', ')}`);
|
||
console.log(`📦 [TURN14-FETCH] ========================================\n`);
|
||
|
||
appendJobLog(jobId, `[FETCH-OK] Found ${validItems.length} products for brand ${brandId}`);
|
||
return { items: validItems, fitmentTags };
|
||
} catch (err) {
|
||
console.error(`❌ [TURN14-FETCH] Exception fetching brand ${brandId}: ${err.message}`);
|
||
console.error(`❌ [TURN14-FETCH] Stack: ${err.stack}`);
|
||
appendJobLog(jobId, `[FETCH-FAIL] Error fetching brand ${brandId}: ${err.message}`);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Add one product to Shopify store
|
||
// ---------------------------------------------------------------------------
|
||
const AddProductToStore = async (shop, accessToken, product, jobId, locationId) => {
|
||
const SHOP = shop;
|
||
const ACCESS_TOKEN = accessToken;
|
||
const item = product;
|
||
const attrs = item.attributes;
|
||
|
||
const productLabel = attrs.product_name || attrs.part_number || `Item ${item.id}`;
|
||
|
||
console.log(`\n🛒 [PRODUCT-START] ============================================`);
|
||
console.log(`🛒 [PRODUCT-START] Product: ${productLabel}`);
|
||
console.log(`🛒 [PRODUCT-START] Part Number: ${attrs.part_number || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Mfr Part Number: ${attrs.mfr_part_number || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Turn14 Item ID: ${item.id}`);
|
||
console.log(`🛒 [PRODUCT-START] Brand: ${attrs.brand || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Category: ${attrs.category || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Subcategory: ${attrs.subcategory || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Price: ${attrs.price || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Compare Price: ${attrs.compare_price || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Purchase Cost: ${attrs.purchase_cost || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Barcode: ${attrs.barcode || 'N/A'}`);
|
||
console.log(`🛒 [PRODUCT-START] Images count: ${(attrs.files || []).filter(f => f.type === 'Image').length}`);
|
||
console.log(`🛒 [PRODUCT-START] Shop: ${SHOP}`);
|
||
console.log(`🛒 [PRODUCT-START] Location ID: ${locationId || 'NOT SET'}`);
|
||
|
||
// Inventory quantity
|
||
const inventoryData = attrs.inventorydata?.inventory || {};
|
||
const totalQuantity = Object.values(inventoryData).reduce((sum, val) => sum + val, 0);
|
||
console.log(`🛒 [PRODUCT-START] Inventory data: ${JSON.stringify(inventoryData)}`);
|
||
console.log(`🛒 [PRODUCT-START] Total quantity: ${totalQuantity}`);
|
||
|
||
// SEO (stubbed — returns empty so we use defaults)
|
||
let parsed = { seo_title: '', seo_description: '' };
|
||
const { seo_title, seo_description } = parsed;
|
||
|
||
// Fitment tags
|
||
const globalUniqueFitmentMap = { make: new Set(), model: new Set(), year: new Set(), drive: new Set(), baseModel: new Set() };
|
||
const tags_raw = attrs?.fitmmentTags || {};
|
||
for (const key in globalUniqueFitmentMap) {
|
||
if (tags_raw[key]) tags_raw[key].forEach(v => globalUniqueFitmentMap[key].add(v));
|
||
}
|
||
const convertedFitment = {};
|
||
for (const key in globalUniqueFitmentMap) convertedFitment[key] = Array.from(globalUniqueFitmentMap[key]);
|
||
const allFitmentTags = Array.from(new Set(Object.values(convertedFitment).flat()));
|
||
console.log(`🔖 [PRODUCT-TAGS] Fitment makes: ${convertedFitment.make?.join(', ') || 'none'}`);
|
||
console.log(`🔖 [PRODUCT-TAGS] Fitment models: ${convertedFitment.model?.slice(0, 10).join(', ') || 'none'}${convertedFitment.model?.length > 10 ? ` (+${convertedFitment.model.length - 10} more)` : ''}`);
|
||
console.log(`🔖 [PRODUCT-TAGS] Fitment years: ${convertedFitment.year?.slice(0, 5).join(', ') || 'none'}${convertedFitment.year?.length > 5 ? ` (+${convertedFitment.year.length - 5} more)` : ''}`);
|
||
console.log(`🔖 [PRODUCT-TAGS] Total fitment tags: ${allFitmentTags.length}`);
|
||
|
||
const client = axios.create({
|
||
baseURL: `https://${SHOP}/admin/api/2025-10/graphql.json`,
|
||
headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN, 'Content-Type': 'application/json' },
|
||
});
|
||
|
||
try {
|
||
// ── STEP 1: Collections ──────────────────────────────────────────────────
|
||
const category = attrs.category || '';
|
||
const subcategory = attrs.subcategory || '';
|
||
const brand = attrs.brand || '';
|
||
const subcats = subcategory.split(/[,\/]/).map(s => s.trim()).filter(Boolean);
|
||
const collectionTitles = Array.from(new Set([category, ...subcats, brand, ...allFitmentTags].filter(Boolean)));
|
||
|
||
console.log(`📁 [COLLECTIONS] Titles to find/create: [${collectionTitles.join(' | ')}]`);
|
||
console.log(`📁 [COLLECTIONS] Total collections needed: ${collectionTitles.length}`);
|
||
|
||
const collectionIds = [];
|
||
|
||
for (const title of collectionTitles) {
|
||
console.log(`📁 [COLLECTIONS] Looking up: "${title}"`);
|
||
const lookupResp = await client.post('', {
|
||
query: `query { collections(first: 1, query: "title:\\"${title}\\" AND collection_type:manual") { nodes { id } } }`
|
||
});
|
||
const existing = lookupResp.data.data.collections.nodes;
|
||
if (existing.length) {
|
||
console.log(`📁 [COLLECTIONS] ✅ Found existing: ${existing[0].id}`);
|
||
collectionIds.push(existing[0].id);
|
||
continue;
|
||
}
|
||
console.log(`📁 [COLLECTIONS] ➕ Creating new collection: "${title}"`);
|
||
const createResp = await client.post('', {
|
||
query: `mutation collectionCreate($input: CollectionInput!) { collectionCreate(input: $input) { collection { id } userErrors { field message } } }`,
|
||
variables: { input: { title } },
|
||
});
|
||
const createData = createResp.data.data.collectionCreate;
|
||
if (createData.userErrors.length) {
|
||
console.error(`📁 [COLLECTIONS] ❌ Failed to create "${title}": ${JSON.stringify(createData.userErrors)}`);
|
||
throw new Error(`Could not create collection "${title}": ${createData.userErrors.map(e => e.message).join(', ')}`);
|
||
}
|
||
console.log(`📁 [COLLECTIONS] ✅ Created: ${createData.collection.id}`);
|
||
collectionIds.push(createData.collection.id);
|
||
}
|
||
|
||
console.log(`📁 [COLLECTIONS] Done. ${collectionIds.length} collection IDs collected`);
|
||
|
||
// ── STEP 2: Build tags ───────────────────────────────────────────────────
|
||
const productTags = [
|
||
attrs.category,
|
||
...subcats,
|
||
...allFitmentTags,
|
||
attrs.brand,
|
||
attrs.part_number,
|
||
attrs.mfr_part_number,
|
||
attrs.price_group,
|
||
attrs.units_per_sku && `${attrs.units_per_sku} per SKU`,
|
||
attrs.barcode,
|
||
].filter(Boolean).map(t => t.trim());
|
||
console.log(`🏷️ [TAGS] Total product tags: ${productTags.length} — ${productTags.slice(0, 10).join(', ')}${productTags.length > 10 ? '...' : ''}`);
|
||
|
||
// ── STEP 3: Media ────────────────────────────────────────────────────────
|
||
const mediaInputs = (attrs.files || [])
|
||
.filter(f => f.type === 'Image' && f.url)
|
||
.map(file => ({
|
||
originalSource: file.url,
|
||
mediaContentType: 'IMAGE',
|
||
alt: `${attrs.product_name} — ${file.media_content}`,
|
||
}));
|
||
console.log(`🖼️ [MEDIA] Image inputs: ${mediaInputs.length}`);
|
||
if (mediaInputs.length > 0) {
|
||
console.log(`🖼️ [MEDIA] First image URL: ${mediaInputs[0].originalSource}`);
|
||
}
|
||
|
||
// ── STEP 4: Description ──────────────────────────────────────────────────
|
||
const marketDescs = (attrs.descriptions || [])
|
||
.filter(d => d.type === 'Market Description')
|
||
.map(d => d.description);
|
||
const descriptionHtml = marketDescs.length
|
||
? marketDescs.reduce((a, b) => (b.length > a.length ? b : a))
|
||
: attrs.part_description;
|
||
console.log(`📝 [DESC] Market descriptions found: ${marketDescs.length}`);
|
||
console.log(`📝 [DESC] Description length: ${(descriptionHtml || '').length} chars`);
|
||
|
||
const handle = slugify(item.id);
|
||
console.log(`🔗 [HANDLE] Generated handle: "${handle}"`);
|
||
|
||
// ── STEP 5: Dedup check ──────────────────────────────────────────────────
|
||
console.log(`🔍 [DEDUP] Checking if handle "${handle}" already exists in Shopify...`);
|
||
const searchRes = await client.post('', {
|
||
query: `query { products(first: 1, query: "handle:${handle}") { nodes { id handle } } }`
|
||
});
|
||
const exists = searchRes.data?.data?.products?.nodes?.length > 0;
|
||
if (exists) {
|
||
const existingId = searchRes.data.data.products.nodes[0].id;
|
||
console.log(`🔍 [DEDUP] ⚠️ DUPLICATE — handle "${handle}" exists as ${existingId} — SKIPPING`);
|
||
appendJobLog(jobId, `[SKIP] ${attrs.part_number} — handle "${handle}" already exists`);
|
||
return { action: 'skipped', handle, product: attrs.product_name || attrs.part_number };
|
||
}
|
||
console.log(`🔍 [DEDUP] ✅ No duplicate found — proceeding to create`);
|
||
|
||
// ── STEP 6: Create product ───────────────────────────────────────────────
|
||
console.log(`🆕 [CREATE] Creating product in Shopify...`);
|
||
console.log(`🆕 [CREATE] Title: "${attrs.product_name}"`);
|
||
console.log(`🆕 [CREATE] Vendor: "${attrs.brand}"`);
|
||
console.log(`🆕 [CREATE] Product type: "${attrs.category}"`);
|
||
console.log(`🆕 [CREATE] Collections to join: ${collectionIds.length}`);
|
||
console.log(`🆕 [CREATE] Media: ${mediaInputs.length} images`);
|
||
|
||
const createProdRes = await client.post('', {
|
||
query: `
|
||
mutation ProductCreate($product: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
||
productCreate(product: $product, media: $media) {
|
||
product {
|
||
id
|
||
variants(first: 1) { nodes { id inventoryItem { id } price compareAtPrice barcode } }
|
||
}
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`,
|
||
variables: {
|
||
product: {
|
||
title: attrs.product_name,
|
||
descriptionHtml: descriptionHtml,
|
||
vendor: attrs.brand,
|
||
productType: attrs.category,
|
||
handle,
|
||
tags: productTags,
|
||
collectionsToJoin: collectionIds,
|
||
status: 'ACTIVE',
|
||
},
|
||
media: mediaInputs,
|
||
},
|
||
});
|
||
|
||
const prodErrs = createProdRes.data.data?.productCreate?.userErrors || [];
|
||
if (prodErrs.length) {
|
||
console.error(`🆕 [CREATE] ❌ productCreate userErrors: ${JSON.stringify(prodErrs)}`);
|
||
const taken = prodErrs.some(e => /already in use/i.test(e.message));
|
||
if (taken) {
|
||
console.log(`🆕 [CREATE] ⚠️ Duplicate handle — SKIPPING`);
|
||
appendJobLog(jobId, `[SKIP] ${attrs.part_number} — duplicate handle`);
|
||
return { action: 'skipped', handle, product: attrs.product_name || attrs.part_number };
|
||
}
|
||
throw new Error(`ProductCreate errors: ${prodErrs.map(e => e.message).join(', ')}`);
|
||
}
|
||
|
||
const createdProduct = createProdRes.data.data.productCreate.product;
|
||
const variantNode = createdProduct.variants?.nodes?.[0];
|
||
console.log(`🆕 [CREATE] ✅ Product created! Shopify Product ID: ${createdProduct.id}`);
|
||
console.log(`🆕 [CREATE] Variant ID: ${variantNode?.id || 'NOT FOUND'}`);
|
||
console.log(`🆕 [CREATE] Inventory Item ID: ${variantNode?.inventoryItem?.id || 'NOT FOUND'}`);
|
||
|
||
if (!variantNode) {
|
||
console.error(`🆕 [CREATE] ❌ No variant node returned — cannot continue`);
|
||
return null;
|
||
}
|
||
|
||
const variantId = variantNode.id;
|
||
const inventoryItemId = variantNode.inventoryItem?.id;
|
||
|
||
// ── STEP 7: Pricing ──────────────────────────────────────────────────────
|
||
console.log(`💰 [PRICING] Reading pricing config from Shopify metafield...`);
|
||
const pricingConfigRes = await client.post('', {
|
||
query: `query { shop { metafield(namespace: "turn14", key: "pricing_config") { value } } }`
|
||
});
|
||
let priceType = 'map';
|
||
let percentage = 0;
|
||
const pricingMf = pricingConfigRes.data?.data?.shop?.metafield;
|
||
if (pricingMf?.value) {
|
||
try {
|
||
const p = JSON.parse(pricingMf.value);
|
||
priceType = p.priceType || 'map';
|
||
percentage = Number(p.percentage) || 0;
|
||
console.log(`💰 [PRICING] Config found — priceType: "${priceType}", percentage: ${percentage}%`);
|
||
} catch (e) {
|
||
console.warn(`💰 [PRICING] Failed to parse pricing config JSON: ${e.message}`);
|
||
}
|
||
} else {
|
||
console.log(`💰 [PRICING] No pricing config metafield found — using defaults (MAP, 0%)`);
|
||
}
|
||
|
||
const baseprice = parseFloat(attrs.price) || 0;
|
||
let price = baseprice;
|
||
if (priceType === 'percentage') price = baseprice + (baseprice * (percentage / 100));
|
||
|
||
const comparePrice = parseFloat(attrs.compare_price) || null;
|
||
const barcode = attrs.barcode || '';
|
||
const weightValue = parseFloat(attrs.dimensions?.[0]?.weight) || 0;
|
||
|
||
console.log(`💰 [PRICING] Base MAP price: $${baseprice}`);
|
||
console.log(`💰 [PRICING] Final price: $${price.toFixed(2)} (${priceType === 'percentage' ? `${percentage}% markup` : 'MAP as-is'})`);
|
||
console.log(`💰 [PRICING] Compare at price: ${comparePrice ? `$${comparePrice}` : 'not set'}`);
|
||
console.log(`💰 [PRICING] Purchase cost: $${attrs.purchase_cost || 0}`);
|
||
console.log(`💰 [PRICING] Barcode: "${barcode || 'none'}"`);
|
||
console.log(`💰 [PRICING] Weight: ${weightValue} lbs`);
|
||
|
||
// ── STEP 8: Variant update ───────────────────────────────────────────────
|
||
console.log(`🔧 [VARIANT] Updating variant — SKU: "${attrs.part_number}", price: $${price.toFixed(2)}`);
|
||
const bulkRes = await client.post('', {
|
||
query: `
|
||
mutation UpdateProductVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||
productVariants { id price compareAtPrice barcode inventoryItem { sku measurement { weight { value unit } } tracked } }
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`,
|
||
variables: {
|
||
productId: createdProduct.id,
|
||
variants: [{
|
||
id: variantId,
|
||
price,
|
||
...(comparePrice !== null && { compareAtPrice: comparePrice }),
|
||
...(barcode && { barcode }),
|
||
inventoryItem: { sku: attrs.part_number, measurement: { weight: { value: weightValue, unit: 'POUNDS' } } },
|
||
}],
|
||
},
|
||
});
|
||
const bulkErrs = bulkRes.data.data.productVariantsBulkUpdate.userErrors;
|
||
if (bulkErrs.length) {
|
||
console.error(`🔧 [VARIANT] ❌ Bulk update errors: ${JSON.stringify(bulkErrs)}`);
|
||
throw new Error(`Bulk update errors: ${bulkErrs.map(e => e.message).join(', ')}`);
|
||
}
|
||
const updatedVariant = bulkRes.data.data.productVariantsBulkUpdate.productVariants?.[0];
|
||
console.log(`🔧 [VARIANT] ✅ Variant updated — price: $${updatedVariant?.price}, SKU: ${updatedVariant?.inventoryItem?.sku}`);
|
||
|
||
// ── STEP 9: Publish ──────────────────────────────────────────────────────
|
||
console.log(`📢 [PUBLISH] Fetching publications...`);
|
||
const publicationsRes = await client.post('', {
|
||
query: `query { publications(first: 10) { edges { node { id name } } } }`
|
||
});
|
||
const allPubs = publicationsRes.data.data.publications.edges;
|
||
console.log(`📢 [PUBLISH] Available publications: ${allPubs.map(p => p.node.name).join(', ')}`);
|
||
|
||
const onlineStorePub = allPubs.find(p => p.node.name === 'Online Store');
|
||
if (onlineStorePub) {
|
||
console.log(`📢 [PUBLISH] Publishing to Online Store (pub ID: ${onlineStorePub.node.id})...`);
|
||
const publishRes = await client.post('', {
|
||
query: `mutation($id: ID!, $publicationId: ID!) { publishablePublish(id: $id, input: { publicationId: $publicationId }) { publishable { ... on Product { id } } userErrors { field message } } }`,
|
||
variables: { id: createdProduct.id, publicationId: onlineStorePub.node.id },
|
||
});
|
||
const publishErrs = publishRes.data.data.publishablePublish.userErrors;
|
||
if (publishErrs.length) {
|
||
console.error(`📢 [PUBLISH] ❌ Publish errors: ${JSON.stringify(publishErrs)}`);
|
||
throw new Error(`Publish errors: ${publishErrs.map(e => e.message).join(', ')}`);
|
||
}
|
||
console.log(`📢 [PUBLISH] ✅ Published to Online Store`);
|
||
} else {
|
||
console.warn(`📢 [PUBLISH] ⚠️ "Online Store" publication not found — product not published`);
|
||
}
|
||
|
||
// ── STEP 10: Inventory cost ──────────────────────────────────────────────
|
||
console.log(`📦 [INVENTORY] Updating inventory item — cost: $${parseFloat(attrs.purchase_cost) || 0}, tracked: true`);
|
||
const invRes = await client.post('', {
|
||
query: `
|
||
mutation InventoryItemUpdate($id: ID!, $input: InventoryItemInput!) {
|
||
inventoryItemUpdate(id: $id, input: $input) {
|
||
inventoryItem { id sku unitCost { amount currencyCode } tracked }
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`,
|
||
variables: { id: inventoryItemId, input: { cost: parseFloat(attrs.purchase_cost) || 0, tracked: true } },
|
||
});
|
||
const invErrs = invRes.data.data.inventoryItemUpdate.userErrors;
|
||
if (invErrs.length) {
|
||
console.error(`📦 [INVENTORY] ❌ inventoryItemUpdate errors: ${JSON.stringify(invErrs)}`);
|
||
throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(', ')}`);
|
||
}
|
||
const invItem = invRes.data.data.inventoryItemUpdate.inventoryItem;
|
||
console.log(`📦 [INVENTORY] ✅ Cost set to $${invItem?.unitCost?.amount || 0} ${invItem?.unitCost?.currencyCode || 'USD'}, tracked: ${invItem?.tracked}`);
|
||
|
||
// ── STEP 11: Activate inventory at location ──────────────────────────────
|
||
if (locationId) {
|
||
console.log(`📍 [INVENTORY-LOC] Activating inventory at location: ${locationId}`);
|
||
await client.post('', {
|
||
query: `mutation ActivateInventoryItem($inventoryItemId: ID!, $locationId: ID!) { inventoryActivate(inventoryItemId: $inventoryItemId, locationId: $locationId) { inventoryLevel { id } userErrors { field message } } }`,
|
||
variables: { inventoryItemId, locationId },
|
||
});
|
||
console.log(`📍 [INVENTORY-LOC] ✅ Inventory activated at location`);
|
||
|
||
// ── STEP 12: Set quantity ────────────────────────────────────────────
|
||
console.log(`📍 [INVENTORY-QTY] Setting quantity to ${totalQuantity} at location ${locationId}`);
|
||
await client.post('', {
|
||
query: `
|
||
mutation InventorySet($input: InventorySetQuantitiesInput!) {
|
||
inventorySetQuantities(input: $input) {
|
||
inventoryAdjustmentGroup { createdAt reason }
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`,
|
||
variables: {
|
||
input: {
|
||
name: 'available',
|
||
reason: 'correction',
|
||
referenceDocumentUri: 'logistics://turn14.data4autos.com/inventory',
|
||
ignoreCompareQuantity: true,
|
||
quantities: [{ inventoryItemId, locationId, quantity: totalQuantity, compareQuantity: 1 }],
|
||
},
|
||
},
|
||
});
|
||
console.log(`📍 [INVENTORY-QTY] ✅ Quantity set to ${totalQuantity}`);
|
||
} else {
|
||
console.warn(`📍 [INVENTORY-LOC] ⚠️ No locationId on token record — inventory NOT activated or set`);
|
||
}
|
||
|
||
// ── STEP 13: SEO ─────────────────────────────────────────────────────────
|
||
const finalSeoTitle = seo_title || `${attrs.product_name} | Auto Parts`;
|
||
const finalSeoDesc = seo_description || `Find high-quality ${attrs.product_name} built for reliability and performance.`;
|
||
console.log(`🔍 [SEO] Setting SEO title: "${finalSeoTitle}"`);
|
||
console.log(`🔍 [SEO] Setting SEO description (${finalSeoDesc.length} chars)`);
|
||
await client.post('', {
|
||
query: `
|
||
mutation ProductUpdate($product: ProductUpdateInput!) {
|
||
productUpdate(product: $product) {
|
||
product { id seo { title description } }
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`,
|
||
variables: {
|
||
product: {
|
||
id: createdProduct.id,
|
||
seo: {
|
||
title: finalSeoTitle,
|
||
description: finalSeoDesc,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
console.log(`🔍 [SEO] ✅ SEO fields updated`);
|
||
|
||
console.log(`✅ [PRODUCT-DONE] ============================================`);
|
||
console.log(`✅ [PRODUCT-DONE] SUCCESS: "${productLabel}"`);
|
||
console.log(`✅ [PRODUCT-DONE] Shopify ID: ${createdProduct.id}`);
|
||
console.log(`✅ [PRODUCT-DONE] Handle: ${handle}`);
|
||
console.log(`✅ [PRODUCT-DONE] Part#: ${attrs.part_number}`);
|
||
console.log(`✅ [PRODUCT-DONE] Price: $${price.toFixed(2)}`);
|
||
console.log(`✅ [PRODUCT-DONE] Qty: ${totalQuantity}`);
|
||
console.log(`✅ [PRODUCT-DONE] ============================================\n`);
|
||
|
||
appendJobLog(jobId, `[PRODUCT-OK] Created: ${attrs.product_name} (${attrs.part_number})`);
|
||
return { action: 'created', productId: createdProduct.id, handle, product: attrs.product_name };
|
||
|
||
} catch (err) {
|
||
console.error(`❌ [PRODUCT-FAIL] ============================================`);
|
||
console.error(`❌ [PRODUCT-FAIL] FAILED: "${productLabel}"`);
|
||
console.error(`❌ [PRODUCT-FAIL] Part#: ${attrs.part_number || 'N/A'}`);
|
||
console.error(`❌ [PRODUCT-FAIL] Error: ${err.message}`);
|
||
console.error(`❌ [PRODUCT-FAIL] Stack: ${err.stack}`);
|
||
console.error(`❌ [PRODUCT-FAIL] ============================================\n`);
|
||
appendJobLog(jobId, `[PRODUCT-FAIL] ${attrs.product_name || attrs.part_number}: ${err.message}`);
|
||
return { action: 'failed', product: attrs.product_name || attrs.part_number, error: err.message };
|
||
}
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// POST /manageproducts — start import job
|
||
// ---------------------------------------------------------------------------
|
||
router.post('/', async (req, res) => {
|
||
const { shop, brandID, brandName, turn14accessToken, productCount, selectedProductIds } = req.body;
|
||
|
||
console.log(`\n🚀 [JOB-REQUEST] ================================================`);
|
||
console.log(`🚀 [JOB-REQUEST] POST /manageproducts received`);
|
||
console.log(`🚀 [JOB-REQUEST] Shop: ${shop}`);
|
||
console.log(`🚀 [JOB-REQUEST] Brand ID: ${brandID}`);
|
||
console.log(`🚀 [JOB-REQUEST] Brand Name: ${brandName}`);
|
||
console.log(`🚀 [JOB-REQUEST] Selected product IDs count: ${Array.isArray(selectedProductIds) ? selectedProductIds.length : 'NOT ARRAY'}`);
|
||
console.log(`🚀 [JOB-REQUEST] Selected IDs: ${JSON.stringify(selectedProductIds?.slice(0, 10))}${selectedProductIds?.length > 10 ? `... (+${selectedProductIds.length - 10} more)` : ''}`);
|
||
console.log(`🚀 [JOB-REQUEST] Turn14 token (first 20): ${turn14accessToken?.slice(0, 20)}...`);
|
||
console.log(`🚀 [JOB-REQUEST] ================================================\n`);
|
||
|
||
if (!shop) return res.status(400).json({ error: 'Missing shop' });
|
||
if (!turn14accessToken) return res.status(400).json({ error: 'Missing turn14accessToken' });
|
||
if (!brandID) return res.status(400).json({ error: 'Missing brandID' });
|
||
if (!Array.isArray(selectedProductIds) || selectedProductIds.length === 0) {
|
||
return res.status(400).json({ error: 'selectedProductIds must be a non-empty array' });
|
||
}
|
||
|
||
const job = createJob({
|
||
shop,
|
||
brandId: brandID,
|
||
brandName: brandName || `Brand ${brandID}`,
|
||
totalSelected: selectedProductIds.length,
|
||
});
|
||
|
||
console.log(`🆔 [JOB-CREATED] Job ID: ${job.id}`);
|
||
console.log(`🆔 [JOB-CREATED] Brand: ${brandName || brandID}`);
|
||
console.log(`🆔 [JOB-CREATED] Total to import: ${selectedProductIds.length}`);
|
||
|
||
log(shop, `[JOB] Created job ${job.id} for brand ${brandID} — ${selectedProductIds.length} products selected`);
|
||
|
||
res.json({ processId: job.id, jobId: job.id, status: 'started' });
|
||
|
||
// Run async — do not await
|
||
(async () => {
|
||
try {
|
||
console.log(`\n⚙️ [JOB-RUN] ====================================================`);
|
||
console.log(`⚙️ [JOB-RUN] Starting async import for job ${job.id}`);
|
||
|
||
updateJob(job.id, { status: 'fetching_products', step: 'fetching_products', detail: `Fetching products for brand ${brandName || brandID} from Turn14...` });
|
||
|
||
// ── Get Shopify token for this shop ──────────────────────────────────
|
||
console.log(`🔑 [JOB-TOKEN] Looking up Shopify token for shop: ${shop}`);
|
||
const tokenRecord = getToken(shop);
|
||
if (!tokenRecord) {
|
||
console.error(`🔑 [JOB-TOKEN] ❌ No token found for shop "${shop}" in tokens.json`);
|
||
throw new Error('No token stored for shop — re-authenticate');
|
||
}
|
||
console.log(`🔑 [JOB-TOKEN] ✅ Token found`);
|
||
console.log(`🔑 [JOB-TOKEN] Token (first 20): ${tokenRecord.accessToken?.slice(0, 20)}...`);
|
||
console.log(`🔑 [JOB-TOKEN] Scope: ${tokenRecord.scope || 'N/A'}`);
|
||
console.log(`🔑 [JOB-TOKEN] Saved at: ${tokenRecord.savedAt || 'N/A'}`);
|
||
console.log(`🔑 [JOB-TOKEN] Location ID: ${tokenRecord.locationId || 'NOT SET ⚠️'}`);
|
||
console.log(`🔑 [JOB-TOKEN] Fulfillment Service: ${tokenRecord.fulfillmentService ? JSON.stringify(tokenRecord.fulfillmentService).slice(0, 80) : 'NOT SET'}`);
|
||
|
||
const locationId = tokenRecord.locationId || null;
|
||
const accessToken = tokenRecord.accessToken;
|
||
|
||
if (!locationId) {
|
||
console.warn(`⚠️ [JOB-TOKEN] locationId is NULL — inventory will NOT be set for products`);
|
||
}
|
||
|
||
// ── Step 1: Fetch products from Turn14 ───────────────────────────────
|
||
console.log(`\n📦 [JOB-STEP-1] Fetching all products for brand ${brandID} from Turn14...`);
|
||
const products_res = await GetAllProductsOfBranch(brandID, turn14accessToken, shop, job.id);
|
||
if (!products_res) {
|
||
console.error(`❌ [JOB-STEP-1] Turn14 fetch returned null — aborting job`);
|
||
throw new Error(`Failed to fetch products from Turn14 for brand ${brandID}`);
|
||
}
|
||
|
||
const allItems = products_res.items;
|
||
console.log(`📦 [JOB-STEP-1] Total items from Turn14: ${allItems.length}`);
|
||
|
||
// ── Step 2: Filter to selected IDs ───────────────────────────────────
|
||
console.log(`\n🔽 [JOB-STEP-2] Filtering to selected ${selectedProductIds.length} product IDs...`);
|
||
const products = allItems.filter(item => selectedProductIds.includes(item.id));
|
||
const total = products.length;
|
||
console.log(`🔽 [JOB-STEP-2] Matched ${total} out of ${allItems.length} Turn14 items`);
|
||
|
||
if (total === 0) {
|
||
console.warn(`⚠️ [JOB-STEP-2] No matching products found! Check if selected IDs exist in brand ${brandID}`);
|
||
console.warn(`⚠️ [JOB-STEP-2] Selected IDs: ${JSON.stringify(selectedProductIds)}`);
|
||
console.warn(`⚠️ [JOB-STEP-2] Available IDs (first 10): ${allItems.slice(0, 10).map(i => i.id).join(', ')}`);
|
||
}
|
||
|
||
updateJob(job.id, {
|
||
status: 'importing',
|
||
step: 'importing',
|
||
detail: `Starting import of ${total} products...`,
|
||
liveStats: { total, processed: 0, created: 0, skipped: 0, failed: 0, remaining: total, successRate: 0, label: `0/${total}` },
|
||
});
|
||
appendJobLog(job.id, `[IMPORT-START] Importing ${total} products for brand ${brandName || brandID}`);
|
||
|
||
console.log(`\n🏁 [JOB-STEP-3] Starting product-by-product import (${total} products)...`);
|
||
console.log(`⚙️ [JOB-STEP-3] ====================================================\n`);
|
||
|
||
let created = 0, skipped = 0, failed = 0;
|
||
|
||
for (let i = 0; i < products.length; i++) {
|
||
if (isJobCancelled(job.id)) {
|
||
console.log(`🛑 [JOB-CANCEL] Job ${job.id} cancelled at product ${i + 1}/${total}`);
|
||
appendJobLog(job.id, '[CANCEL] Import cancelled by user');
|
||
finishJob(job.id, 'cancelled');
|
||
return;
|
||
}
|
||
|
||
const item = products[i];
|
||
const attrs = item.attributes;
|
||
const productLabel = attrs.product_name || attrs.part_number || `Item ${item.id}`;
|
||
const partNum = attrs.part_number || '';
|
||
|
||
console.log(`\n📋 [PRODUCT-LOOP] ─────────────────────────────────────────`);
|
||
console.log(`📋 [PRODUCT-LOOP] Product ${i + 1} of ${total}`);
|
||
console.log(`📋 [PRODUCT-LOOP] Name: ${productLabel}`);
|
||
console.log(`📋 [PRODUCT-LOOP] Part#: ${partNum}`);
|
||
console.log(`📋 [PRODUCT-LOOP] Turn14 ID: ${item.id}`);
|
||
console.log(`📋 [PRODUCT-LOOP] Running totals → created:${created} skipped:${skipped} failed:${failed}`);
|
||
|
||
updateJob(job.id, {
|
||
currentProduct: { name: productLabel, partNumber: partNum, number: i + 1, total },
|
||
detail: `Importing product ${i + 1}/${total}: ${productLabel}`,
|
||
});
|
||
appendJobLog(job.id, `[PRODUCT] (${i + 1}/${total}) ${productLabel}`);
|
||
|
||
const result = await AddProductToStore(shop, accessToken, item, job.id, locationId);
|
||
|
||
if (result?.action === 'created') {
|
||
created++;
|
||
console.log(`📋 [PRODUCT-LOOP] ✅ CREATED — total created so far: ${created}`);
|
||
} else if (result?.action === 'skipped') {
|
||
skipped++;
|
||
console.log(`📋 [PRODUCT-LOOP] ⏭️ SKIPPED — total skipped so far: ${skipped}`);
|
||
} else if (result?.action === 'failed') {
|
||
failed++;
|
||
console.error(`📋 [PRODUCT-LOOP] ❌ FAILED (${result?.error}) — total failed so far: ${failed}`);
|
||
recordProductError(job.id, { index: i + 1, product: productLabel, error: result?.error || 'unknown error' });
|
||
} else {
|
||
console.warn(`📋 [PRODUCT-LOOP] ⚠️ NULL result returned for ${productLabel}`);
|
||
failed++;
|
||
}
|
||
|
||
if (result) recordProductResult(job.id, result);
|
||
|
||
const processed = i + 1;
|
||
const successRate = processed > 0 ? Number((((processed - failed) / processed) * 100).toFixed(1)) : 0;
|
||
|
||
console.log(`📋 [PRODUCT-LOOP] Progress: ${processed}/${total} (${successRate}% success rate)`);
|
||
|
||
updateJob(job.id, {
|
||
liveStats: { total, processed, created, skipped, failed, remaining: total - processed, successRate, label: `${processed}/${total}` },
|
||
});
|
||
|
||
if (processed % 5 === 0 || processed === total) {
|
||
appendJobLog(job.id, `[STATS] total=${total} processed=${processed} created=${created} skipped=${skipped} failed=${failed} rate=${successRate}`);
|
||
}
|
||
}
|
||
|
||
updateJob(job.id, { currentProduct: null });
|
||
appendJobLog(job.id, `[IMPORT-DONE] Finished: ${created} created, ${skipped} skipped, ${failed} failed`);
|
||
finishJob(job.id, 'done');
|
||
|
||
console.log(`\n🎉 [JOB-DONE] ====================================================`);
|
||
console.log(`🎉 [JOB-DONE] Job ${job.id} COMPLETED`);
|
||
console.log(`🎉 [JOB-DONE] Shop: ${shop}`);
|
||
console.log(`🎉 [JOB-DONE] Brand: ${brandName || brandID}`);
|
||
console.log(`🎉 [JOB-DONE] Total selected: ${total}`);
|
||
console.log(`🎉 [JOB-DONE] ✅ Created: ${created}`);
|
||
console.log(`🎉 [JOB-DONE] ⏭️ Skipped: ${skipped}`);
|
||
console.log(`🎉 [JOB-DONE] ❌ Failed: ${failed}`);
|
||
console.log(`🎉 [JOB-DONE] Success rate: ${total > 0 ? ((created / total) * 100).toFixed(1) : 0}%`);
|
||
console.log(`🎉 [JOB-DONE] ====================================================\n`);
|
||
|
||
log(shop, `[JOB] ${job.id} completed — created=${created} skipped=${skipped} failed=${failed}`);
|
||
|
||
} catch (err) {
|
||
console.error(`\n💥 [JOB-ERROR] ====================================================`);
|
||
console.error(`💥 [JOB-ERROR] Job ${job.id} CRASHED`);
|
||
console.error(`💥 [JOB-ERROR] Shop: ${shop}`);
|
||
console.error(`💥 [JOB-ERROR] Error: ${err.message}`);
|
||
console.error(`💥 [JOB-ERROR] Stack: ${err.stack}`);
|
||
console.error(`💥 [JOB-ERROR] ====================================================\n`);
|
||
appendJobLog(job.id, `[ERROR] ${err.message}`);
|
||
updateJob(job.id, { status: 'error', step: 'error', detail: err.message, currentProduct: null });
|
||
finishJob(job.id, 'error');
|
||
log(shop, `[JOB] ${job.id} error — ${err.message}`);
|
||
}
|
||
})();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GET /manageproducts/status/:processId — poll job status (legacy + new)
|
||
// ---------------------------------------------------------------------------
|
||
router.get('/status/:processId', (req, res) => {
|
||
const job = getJob(req.params.processId);
|
||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||
|
||
const s = job.liveStats;
|
||
|
||
res.json({
|
||
status: job.status,
|
||
detail: job.detail,
|
||
progress: s.total > 0 ? Math.round((s.processed / s.total) * 100) : 0,
|
||
current: job.currentProduct,
|
||
stats: { total: s.total, processed: s.processed, remaining: s.remaining },
|
||
results: job.results,
|
||
job,
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GET /manageproducts/jobs — list all jobs (optionally ?shop=...)
|
||
// ---------------------------------------------------------------------------
|
||
router.get('/jobs', (req, res) => {
|
||
const shop = req.query.shop || null;
|
||
res.json({ jobs: listJobs(shop) });
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GET /manageproducts/jobs/:jobId — get single job
|
||
// ---------------------------------------------------------------------------
|
||
router.get('/jobs/:jobId', (req, res) => {
|
||
const job = getJob(req.params.jobId);
|
||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||
res.json(job);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// POST /manageproducts/jobs/:jobId/cancel
|
||
// ---------------------------------------------------------------------------
|
||
router.post('/jobs/:jobId/cancel', (req, res) => {
|
||
const job = cancelJob(req.params.jobId);
|
||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||
res.json({ ok: true, job });
|
||
});
|
||
|
||
module.exports = router;
|