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