// 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) => { try { appendJobLog(jobId, `[FETCH] Fetching products for brand ${brandId} from Turn14...`); const res = await fetch( `https://turn14.data4autos.com/v1/items/brandallitemswithfitment/${brandId}`, { headers: { Authorization: `Bearer ${turn14accessToken}`, 'Content-Type': 'application/json', }, } ); const res_data = await res.json(); const data = res_data.items || []; const fitmentTags = res_data.fitmentTags || []; const validItems = Array.isArray(data) ? data.filter(item => item && item.id && item.attributes) : []; appendJobLog(jobId, `[FETCH-OK] Found ${validItems.length} products for brand ${brandId}`); return { items: validItems, fitmentTags }; } catch (err) { 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; // 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())); 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 { const inventoryData = attrs.inventorydata?.inventory || {}; const totalQuantity = Object.values(inventoryData).reduce((sum, val) => sum + val, 0); // 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))); const collectionIds = []; for (const title of collectionTitles) { 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) { collectionIds.push(existing[0].id); continue; } 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) { throw new Error(`Could not create collection "${title}": ${createData.userErrors.map(e => e.message).join(', ')}`); } collectionIds.push(createData.collection.id); } // 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()); // 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}`, })); // 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; const handle = slugify(item.id); // Dedup check 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) { appendJobLog(jobId, `[SKIP] ${attrs.part_number} — handle "${handle}" already exists`); return { action: 'skipped', handle, product: attrs.product_name || attrs.part_number }; } // Create product 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) { const taken = prodErrs.some(e => /already in use/i.test(e.message)); if (taken) { 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]; if (!variantNode) return null; const variantId = variantNode.id; const inventoryItemId = variantNode.inventoryItem?.id; // Pricing 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; } catch {} } 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; // Variant update 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) throw new Error(`Bulk update errors: ${bulkErrs.map(e => e.message).join(', ')}`); // Publish const publicationsRes = await client.post('', { query: `query { publications(first: 10) { edges { node { id name } } } }` }); const onlineStorePub = publicationsRes.data.data.publications.edges.find(p => p.node.name === 'Online Store'); if (onlineStorePub) { 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) throw new Error(`Publish errors: ${publishErrs.map(e => e.message).join(', ')}`); } // Inventory 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) throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(', ')}`); if (locationId) { await client.post('', { query: `mutation ActivateInventoryItem($inventoryItemId: ID!, $locationId: ID!) { inventoryActivate(inventoryItemId: $inventoryItemId, locationId: $locationId) { inventoryLevel { id } userErrors { field message } } }`, variables: { inventoryItemId, 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 }], }, }, }); } // SEO update 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: seo_title || `${attrs.product_name} | Auto Parts`, description: seo_description || `Find high-quality ${attrs.product_name} built for reliability and performance.`, }, }, }, }); appendJobLog(jobId, `[PRODUCT-OK] Created: ${attrs.product_name} (${attrs.part_number})`); return { action: 'created', productId: createdProduct.id, handle, product: attrs.product_name }; } catch (err) { 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; 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, }); 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 { updateJob(job.id, { status: 'fetching_products', step: 'fetching_products', detail: `Fetching products for brand ${brandName || brandID} from Turn14...` }); const tokenRecord = getToken(shop); if (!tokenRecord) throw new Error('No token stored for shop — re-authenticate'); const locationId = tokenRecord.locationId || null; const accessToken = tokenRecord.accessToken; // Step 1: Fetch products from Turn14 const products_res = await GetAllProductsOfBranch(brandID, turn14accessToken, shop, job.id); if (!products_res) throw new Error(`Failed to fetch products from Turn14 for brand ${brandID}`); const allItems = products_res.items; // Step 2: Filter to selected IDs const products = allItems.filter(item => selectedProductIds.includes(item.id)); const total = products.length; 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}`); let created = 0, skipped = 0, failed = 0; for (let i = 0; i < products.length; i++) { if (isJobCancelled(job.id)) { 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 || ''; 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++; else if (result?.action === 'skipped') skipped++; else if (result?.action === 'failed') { failed++; recordProductError(job.id, { index: i + 1, product: productLabel, error: result?.error || 'unknown error' }); } if (result) recordProductResult(job.id, result); const processed = i + 1; const successRate = processed > 0 ? Number((((processed - failed) / processed) * 100).toFixed(1)) : 0; updateJob(job.id, { liveStats: { total, processed, created, skipped, failed, remaining: total - processed, successRate, label: `${processed}/${total}` }, }); // Emit structured line every 5 products for dashboard 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'); log(shop, `[JOB] ${job.id} completed — created=${created} skipped=${skipped} failed=${failed}`); } catch (err) { 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; // Legacy-compatible shape + full job for new dashboard res.json({ // Legacy fields (managebrand.jsx polling) 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, // Full job object for dashboard 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;