// routes/manageBrands.js const express = require('express'); const axios = require('axios'); const { v4: uuid } = require('uuid'); const { getToken } = require('../tokenStore'); const { log } = require('../logger'); const router = express.Router(); const API_VERSION = '2023-10'; // Simple in-memory process tracker const processes = {}; function slugify(str) { return str .toString() .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } const GetAllProductsOfBranch = async (brandId, turn14accessToken, shop, procId) => { var AllProductsOfBrans = []; try { log(shop, `🔍 [${procId}] Fetching products for brand ${brandId}`); const res = await fetch(`https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`, { headers: { Authorization: `Bearer ${turn14accessToken}`, "Content-Type": "application/json", }, }); const data = await res.json(); // Ensure we have an array of valid items const validItems = Array.isArray(data) ? data.filter(item => item && item.id && item.attributes) : []; AllProductsOfBrans = validItems; log(shop, `📦 [${procId}] Found ${AllProductsOfBrans.length} products for brand ${brandId}`); const items = Array.isArray(AllProductsOfBrans) ? AllProductsOfBrans.slice(0, 1) : []; // const items = Array.isArray(AllProductsOfBrans) ? AllProductsOfBrans : []; log(shop, `📝 [${procId}] Processing ${items.length} sample products`); return items; } catch (err) { log(shop, `❌ [${procId}] Error fetching items: ${err.message}`); return null; } } const AddProductToStore = async (shop, accessToken, product, procId) => { var results = []; const SHOP = shop; const ACCESS_TOKEN = accessToken; const item = product; const client = axios.create({ baseURL: `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN, 'Content-Type': 'application/json', }, }); const attrs = item.attributes; try { log(shop, `🛒 [${procId}] Processing product: ${attrs.product_name || attrs.part_number}`); // Build and normalize collection titles 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].filter(Boolean)) ); // Find or create collections, collect their IDs const collectionIds = []; for (const title of collectionTitles) { log(shop, `🏷️ [${procId}] Handling collection: ${title}`); // 1. Query existing manual collection by title const lookupQuery = ` query { collections(first: 1, query: "title:\\"${title}\\" AND collection_type:manual") { nodes { id } } } `; const lookupResp = await client.post('', { query: lookupQuery }); const existing = lookupResp.data.data.collections.nodes; if (existing.length) { log(shop, `✅ [${procId}] Found existing collection: ${title}`); collectionIds.push(existing[0].id); continue; } // 2. Otherwise, create it log(shop, `➕ [${procId}] Creating new collection: ${title}`); const createMutation = ` mutation collectionCreate($input: CollectionInput!) { collectionCreate(input: $input) { collection { id } userErrors { field message } } } `; const createResp = await client.post('', { query: createMutation, 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(', ') ); } const newId = createData.collection.id; log(shop, `✨ [${procId}] Created collection: ${title} (ID: ${newId})`); collectionIds.push(newId); } // Build tags const tags = [ attrs.category, ...subcats, 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()); // Prepare media inputs 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}`, })); // Pick the longest "Market Description" or fallback to part_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; log(shop, `🔄 [${procId}] Creating product: ${attrs.product_name}`); const createProdRes = await client.post('', { query: ` mutation($prod: ProductInput!, $media: [CreateMediaInput!]) { productCreate(input: $prod, media: $media) { product { id variants(first: 1) { nodes { id inventoryItem { id } price compareAtPrice barcode } } } userErrors { field message } } } `, variables: { prod: { title: attrs.product_name, descriptionHtml: descriptionHtml, vendor: attrs.brand, productType: attrs.category, handle: slugify(item.id + "-" + (attrs.mfr_part_number || attrs.product_name)), tags, collectionsToJoin: collectionIds, status: "ACTIVE", }, media: mediaInputs, }, }); const createProdJson = createProdRes.data; const prodErrs = createProdJson.data?.productCreate?.userErrors || []; if (prodErrs.length) { const taken = prodErrs.some(e => /already in use/i.test(e.message)); if (taken) { log(shop, `⏭️ [${procId}] Skipping duplicate product: ${attrs.part_number}`); results.push({ skippedHandle: attrs.part_number, reason: "handle in use" }); return null; } throw new Error(`ProductCreate errors: ${prodErrs.map(e => e.message).join(", ")}`); } const product = createProdJson.data.productCreate.product; const variantNode = product.variants?.nodes?.[0]; if (!variantNode) { log(shop, `⚠️ [${procId}] No variant found for product: ${product.id}`); return null; } const variantId = variantNode.id; const inventoryItemId = variantNode.inventoryItem?.id; // Bulk-update variant (price, compare-at, barcode) const price = parseFloat(attrs.price) || 1000; const comparePrice = parseFloat(attrs.compare_price) || null; const barcode = attrs.barcode || ""; log(shop, `💲 [${procId}] Updating pricing for variant: ${variantId}`); // const bulkRes = await client.post('', { // query: ` // mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { // productVariantsBulkUpdate(productId: $productId, variants: $variants) { // productVariants { // id // price // compareAtPrice // barcode // } // userErrors { // field // message // } // } // } // `, // variables: { // productId: product.id, // variants: [{ // id: variantId, // price, // ...(comparePrice !== null && { compareAtPrice: comparePrice }), // ...(barcode && { barcode }), // }], // }, // }); const bulkRes = await client.post('', { query: ` mutation UpdateProductVariant( $productId: ID!, $variants: [ProductVariantsBulkInput!]! ) { productVariantsBulkUpdate(productId: $productId, variants: $variants) { productVariants { id price compareAtPrice barcode sku inventoryItem { measurement { weight { value unit } } tracked } } userErrors { field message } } } `, variables: { productId: product.id, variants: [{ id: variantId, price, ...(comparePrice !== null && { compareAtPrice: comparePrice }), ...(barcode && { barcode }), sku: "SKU1", inventoryItem: { measurement: { weight: { value: 0.47, unit: "POUNDS" } }, tracked: true } }] }, }); const bulkJson = bulkRes.data; if (bulkJson.errors) { console.error("GraphQL errors:", bulkJson.errors); } if (!bulkJson.data || !bulkJson.data.productVariantsBulkUpdate) { console.error("No productVariantsBulkUpdate in response:", bulkJson); } else { const bulkErrs = bulkJson.data.productVariantsBulkUpdate.userErrors; // process userErrors or productVariants as needed if (bulkErrs.length) { throw new Error(`Bulk update errors: ${bulkErrs.map(e => e.message).join(", ")}`); } log(shop, `✅ [${procId}] Updated variant ${variantId} with price ${price}, compare-at ${comparePrice}, barcode ${barcode}`); } const bulkErrs = bulkJson.data.productVariantsBulkUpdate.userErrors; if (bulkErrs.length) { throw new Error(`Bulk update errors: ${bulkErrs.map(e => e.message).join(", ")}`); } // Fetch the Online Store publication ID log(shop, `📢 [${procId}] Publishing product to Online Store`); const publicationsRes = await client.post('', { query: ` query { publications(first: 10) { edges { node { id name } } } } ` }); const publicationsJson = publicationsRes.data; const onlineStorePublication = publicationsJson.data.publications.edges.find( pub => pub.node.name === 'Online Store' ); const onlineStorePublicationId = onlineStorePublication ? onlineStorePublication.node.id : null; if (onlineStorePublicationId) { const publishRes = await client.post('', { query: ` mutation($id: ID!, $publicationId: ID!) { publishablePublish(id: $id, input: { publicationId: $publicationId }) { publishable { ... on Product { id title status } } userErrors { field message } } } `, variables: { id: product.id, publicationId: onlineStorePublicationId, }, }); const publishJson = publishRes.data; const publishErrs = publishJson.data.publishablePublish.userErrors; if (publishErrs.length) { throw new Error(`Publish errors: ${publishErrs.map(e => e.message).join(", ")}`); } log(shop, `🌐 [${procId}] Published product to Online Store`); } else { throw new Error("Online Store publication not found."); } const costPerItem = parseFloat(attrs.purchase_cost) || 0; const weightValue = parseFloat(attrs.dimensions?.[0]?.weight) || 0; console.log(`[${procId}] Updating inventory for product ${product.id} with cost ${costPerItem} and weight ${weightValue}`); log(shop, `📦 [${procId}] Updating inventory for product`); const invRes = await client.post('', { query: ` mutation($id: ID!, $input: InventoryItemUpdateInput!) { inventoryItemUpdate(id: $id, input: $input) { inventoryItem { id sku unitCost { amount } tracked measurement { weight { value unit } } } userErrors { field message } } } `, variables: { id: inventoryItemId, input: { cost: parseFloat(attrs.purchase_cost) || 0, sku: attrs.part_number, measurement: { weight: { value: weightValue, unit: "POUNDS" } }, // tracked: true } } }); const invJson = invRes.data; const invErrs = invJson.data.inventoryItemUpdate.userErrors; if (invErrs.length) { throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`); } // Get the updated inventory item from the response const updatedInventoryItem = invJson.data.inventoryItemUpdate.inventoryItem; // Collect results results.push({ productId: product.id, variant: { id: variantId, price: variantNode.price, compareAtPrice: variantNode.compareAtPrice, // sku: updatedInventoryItem.sku || attrs.part_number || '', // barcode: variantNode.barcode || attrs.barcode || '', // weight: updatedInventoryItem?.measurement?.weight?.value || 0, // weightUnit: updatedInventoryItem?.measurement?.weight?.unit || 'kg', }, collections: collectionTitles, tags, }); log(shop, `✅ [${procId}] Successfully processed product: ${attrs.product_name}`); return results; } catch (err) { log(shop, `❌ [${procId}] Error processing product: ${err.message}`); results.push({ error: `Failed to process item ${item.id}: ${err.message}`, product: attrs.product_name || attrs.part_number || 'Unknown' }); return results; } } const GetAllProductsandAddToStore = async (shop, accessToken, brandId, turn14accessToken, procId) => { log(shop, `🔍 [${procId}] Fetching products for brand ${brandId}`); const products = await GetAllProductsOfBranch(brandId, turn14accessToken, shop, procId); if (!products) { log(shop, `⚠️ [${procId}] No products found for brand ${brandId}`); return []; } // Update total products count processes[procId].totalProducts = products.length; processes[procId].processedProducts = 0; const results = []; log(shop, `🔄 [${procId}] Processing ${products.length} products`); for (const [index, item] of products.entries()) { try { // Update current product being processed const attrs = item.attributes; processes[procId].currentProduct = { name: attrs.product_name || attrs.part_number || 'Unknown', number: index + 1, total: products.length }; processes[procId].status = `processing (${index + 1}/${products.length})`; const res = await AddProductToStore(shop, accessToken, item, procId); if (res) results.push(...res); // Update processed count processes[procId].processedProducts = index + 1; processes[procId].detail = `Processed ${index + 1} of ${products.length} products`; } catch (err) { log(shop, `⚠️ [${procId}] Error processing product ${index + 1}: ${err.message}`); results.push({ error: `Failed to process product ${index + 1}: ${err.message}`, product: item.attributes.product_name || item.attributes.part_number || 'Unknown' }); } } // Clear current product when done processes[procId].currentProduct = null; log(shop, `✅ [${procId}] Completed processing ${results.length} products`); return results; } router.post('/', async (req, res) => { const { shop, brandID, turn14accessToken } = req.body; const procId = uuid(); processes[procId] = { status: 'started', detail: null, totalProducts: 0, processedProducts: 0, currentProduct: null, results: [] }; log(shop, `🔔 [${procId}] Starting product import for brand ${brandID}`); res.json({ processId: procId, status: 'started' }); (async () => { try { processes[procId].status = 'fetching_products'; log(shop, `🔍 [${procId}] Fetching token for shop`); // 1. Get token const tokenRecord = getToken(shop); if (!tokenRecord) throw new Error('No token for shop'); processes[procId].status = 'importing_products'; processes[procId].detail = 'Starting product import'; const importResults = await GetAllProductsandAddToStore(shop, tokenRecord.accessToken, brandID, turn14accessToken, procId); log(shop, `✅ [${procId}] Successfully imported ${importResults.length} products`); processes[procId].status = 'done'; processes[procId].detail = `Imported ${importResults.length} products`; processes[procId].results = importResults; } catch (err) { processes[procId].status = 'error'; processes[procId].detail = err.message; log(shop, `❌ [${procId}] Error: ${err.message}`); } })(); }); router.get('/status/:processId', (req, res) => { const info = processes[req.params.processId]; if (!info) return res.status(404).json({ error: 'Not found' }); const response = { status: info.status, detail: info.detail, progress: info.totalProducts > 0 ? Math.round((info.processedProducts / info.totalProducts) * 100) : 0, current: info.currentProduct, stats: { total: info.totalProducts, processed: info.processedProducts, remaining: info.totalProducts - info.processedProducts }, results: info.results || [] }; res.json(response); }); module.exports = router;