MOHAN b7fa2a21a9 debug: add exhaustive console.log to entire product import pipeline
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>
2026-07-02 23:39:35 +05:30

759 lines
38 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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