From 6582ec56413664ce69d64b58aebfc146ad4082f0 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Wed, 10 Jun 2026 02:22:58 +0530 Subject: [PATCH] feat: add live import progress tracking with job store - Add jobStore.js: in-memory job store with rich job objects (liveStats, logs, errors, cancellation, timing, success rate) - Rewrite manageProducts.js: structured logging ([STATS], [PRODUCT-OK], [PRODUCT-FAIL], [SKIP], [FETCH], [CANCEL], etc.), per-product cancel checks, jobStore integration - server.js: expose GET /health, GET /jobs, GET /jobs/:id, POST /jobs/:id/cancel, GET /shops endpoints - tokenStore.js: add listTokens() export Co-Authored-By: Claude Sonnet 4.6 --- jobStore.js | 156 ++++ routes/manageProducts.js | 1571 ++++++++++++-------------------------- server.js | 42 +- tokenStore.js | 6 +- 4 files changed, 694 insertions(+), 1081 deletions(-) create mode 100644 jobStore.js diff --git a/jobStore.js b/jobStore.js new file mode 100644 index 0000000..e860ac2 --- /dev/null +++ b/jobStore.js @@ -0,0 +1,156 @@ +// jobStore.js — in-memory job tracker for product import jobs +const { v4: uuid } = require('uuid'); + +const jobs = {}; + +const MAX_LOGS = 120; +const MAX_ERRORS = 30; +const MAX_RESULTS = 100; + +function nowIso() { + return new Date().toISOString(); +} + +function elapsedSeconds(startedAt) { + if (!startedAt) return 0; + return Number(((Date.now() - new Date(startedAt).getTime()) / 1000).toFixed(1)); +} + +function createJob({ shop, brandId, brandName, totalSelected = 0 }) { + const id = uuid(); + const job = { + id, + shop, + brandId: String(brandId || ''), + brandName: brandName || '', + status: 'started', + step: 'started', + detail: 'Initialising import job...', + currentProduct: null, + liveStats: { + total: totalSelected, + processed: 0, + created: 0, + skipped: 0, + failed: 0, + remaining: totalSelected, + successRate: 0, + label: `0/${totalSelected}`, + }, + errors: [], + logs: [], + results: [], + startedAt: nowIso(), + updatedAt: nowIso(), + finishedAt: null, + durationSeconds: null, + cancelled: false, + }; + jobs[id] = job; + return job; +} + +function updateJob(jobId, patch) { + const job = jobs[jobId]; + if (!job) return null; + Object.assign(job, patch, { updatedAt: nowIso() }); + // Recompute derived stats whenever liveStats is patched + if (patch.liveStats) { + const s = job.liveStats; + s.remaining = Math.max(0, s.total - s.processed); + s.successRate = s.processed > 0 + ? Number((((s.processed - s.failed) / s.processed) * 100).toFixed(1)) + : 0; + s.label = `${s.processed}/${s.total}`; + } + return job; +} + +function appendJobLog(jobId, line, extraPatch = {}) { + const job = jobs[jobId]; + if (!job) return null; + job.logs.push({ at: nowIso(), line }); + if (job.logs.length > MAX_LOGS) job.logs.shift(); + Object.assign(job, extraPatch, { updatedAt: nowIso() }); + return job; +} + +function recordProductResult(jobId, result) { + const job = jobs[jobId]; + if (!job) return null; + job.results.push(result); + if (job.results.length > MAX_RESULTS) job.results.shift(); + job.updatedAt = nowIso(); + return job; +} + +function recordProductError(jobId, errorEntry) { + const job = jobs[jobId]; + if (!job) return null; + job.errors.push(errorEntry); + if (job.errors.length > MAX_ERRORS) job.errors.shift(); + job.updatedAt = nowIso(); + return job; +} + +function finishJob(jobId, status = 'done') { + const job = jobs[jobId]; + if (!job) return null; + const finishedAt = nowIso(); + const durationSeconds = elapsedSeconds(job.startedAt); + const s = job.liveStats; + Object.assign(job, { + status, + step: status === 'done' ? 'completed' : status, + finishedAt, + durationSeconds, + updatedAt: finishedAt, + }); + s.remaining = 0; + s.successRate = s.processed > 0 + ? Number((((s.processed - s.failed) / s.processed) * 100).toFixed(1)) + : 0; + return job; +} + +function cancelJob(jobId) { + const job = jobs[jobId]; + if (!job) return null; + job.cancelled = true; + job.status = 'cancelling'; + job.updatedAt = nowIso(); + return job; +} + +function isJobCancelled(jobId) { + return !!(jobs[jobId]?.cancelled); +} + +function getJob(jobId) { + return jobs[jobId] || null; +} + +function listJobs(shop = null) { + const all = Object.values(jobs); + const filtered = shop ? all.filter(j => j.shop === shop) : all; + return filtered.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt)); +} + +function getLatestJobForShop(shop) { + const shopJobs = listJobs(shop); + return shopJobs[0] || null; +} + +module.exports = { + createJob, + updateJob, + appendJobLog, + recordProductResult, + recordProductError, + finishJob, + cancelJob, + isJobCancelled, + getJob, + listJobs, + getLatestJobForShop, +}; diff --git a/routes/manageProducts.js b/routes/manageProducts.js index e7f1305..6948407 100755 --- a/routes/manageProducts.js +++ b/routes/manageProducts.js @@ -1,1110 +1,523 @@ - // 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 crypto = require('crypto'); - const router = express.Router(); +// 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 seo_llm_client = axios.create({ - baseURL: "https://llm.thedomainnest.com", // 👈 change this - headers: { - "Content-Type": "application/json", +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, }, - timeout: 0, }); - - // Simple in-memory process tracker - const processes = {}; - - function slugify(str) { - return str - .toString() - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + 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 GetAllProductsOfBranch = async (brandId, turn14accessToken, shop, procId, productCount) => { - var AllProductsOfBrans = []; + 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 { - log(shop, `🔍 [${procId}] Fetching products for brand ${brandId}`); - // const res = await fetch(`https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`, { - 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 || []; - // 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 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; - const items = Array.isArray(AllProductsOfBrans) ? AllProductsOfBrans : []; - log(shop, `📝 [${procId}] Processing ${items.length} sample products`); + // 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(', ')}`); - return { items, fitmentTags }; - } catch (err) { - log(shop, `❌ [${procId}] Error fetching items: ${err.message}`); - return null; - } + // 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(', ')}`); } - function extractFirstJsonObject(text) { - if (typeof text !== "string") return text; - - // remove code fences if any - let s = text.trim() - .replace(/^```json\s*/i, "") - .replace(/^```\s*/i, "") - .replace(/```$/i, "") - .trim(); - - // grab first {...} block - const start = s.indexOf("{"); - const end = s.lastIndexOf("}"); - if (start === -1 || end === -1 || end <= start) return null; - - s = s.slice(start, end + 1); - - // common LLM JSON mistakes quick-fix: - // 1) seo_description:" -> "seo_description": - s = s.replace(/(\{|,)\s*(seo_title|seo_description)\s*:/g, '$1"$2":'); - - // 2) "seo_description:" -> "seo_description": - s = s.replace(/"seo_description\s*:\s*/g, '"seo_description":'); - - return s; - } - - - const AddProductToStore = async (shop, accessToken, product, procId, fulfillmentServiceId, locationId) => { - var results = []; - - const { - product_name, - descriptions, - brand, - category, - subcategory, part_number, price - } = product.attributes; - - var seo_product_json_data = { - product_name, - descriptions, - brand, - category, - subcategory, part_number, price - } - - - const requestBody = { - message: `Use the following product JSON as the only source of truth. Create: - 1) seo_title (max 70 characters min 60 characters) - 2) seo_description (max 160 characters min 140 characters) - - Product JSON: - ${JSON.stringify(seo_product_json_data)}`, - - mode: "quality", - - // system_prompt: `You are an SEO metadata generator for automotive performance parts. - - // OUTPUT RULES (STRICT): - // - Output ONLY valid JSON. No markdown. No comments. No extra keys. - // - Output schema exactly: {"seo_title":"","seo_description":""} - // - seo_title MUST be <= 70 characters and >= 60 characters. - // - seo_description MUST be <= 160 characters and >= 140 characters. - // - Use natural, high-intent wording. No keyword stuffing. - // - Must include: Brand + part type + key fitment + finish when available. - // - If space allows, include ONE technical hook from the data. - // - Avoid price, shipping, hype, or compliance claims. - // - Do NOT copy product_name verbatim; rephrase for uniqueness. - // - Title and description must be clearly different. - - // FINAL CHECK: - // - JSON parses correctly. - // - Length limits respected. - // - No text outside JSON.`, - - system_prompt: `You are an SEO metadata generator for automotive performance parts. - - OUTPUT RULES (STRICT): - - Output ONLY valid JSON. No markdown. No comments. No extra keys. - - Output schema exactly: {"seo_title":"","seo_description":""} - - seo_title MUST be <= 70 characters and >= 60 characters. - - seo_description MUST be <= 160 characters and >= 140 characters. - - Use natural, high-intent wording. No keyword stuffing. - - Must include: Brand + part type + key fitment + finish when available. - - If space allows, include ONE technical hook from the data. - - Avoid price, shipping, hype, or compliance claims. - - Do NOT copy product_name verbatim; rephrase for uniqueness. - - Title and description must be clearly different. - - ANTI-REPETITION RULES (MANDATORY): - - NEVER start seo_description with any of these phrases (case-insensitive): - "Upgrade your", "Boost your", "Take your", "Enhance your", "Transform your", - "Elevate your", "Improve your", "Unlock", "Experience", "Introducing" - - Do NOT use the phrase "upgrade your" anywhere in the description. - - Do NOT reuse the same opening 3 words across different products in the same session. - - Avoid generic filler like: "wheel game", "next level", "top-notch", "ultimate", "perfect for". - - DESCRIPTION OPENING STYLE (MUST CHOOSE ONE PER PRODUCT): - Pick ONE of the following opening patterns and write the description accordingly: - 1) Fitment-first: "For [vehicle/fitment], this [part type]..." - 2) Spec-first: "[Key size/spec] [part type] from [Brand]..." - 3) Feature-first: "Built with [feature], this [part type]..." - 4) Finish-first: "[Finish] [part type] that adds..." - 5) Use-case-first: "Ideal for [track/street/OE replacement], this [part type]..." - - CONSISTENCY & QUALITY: - - Keep grammar clean and professional. - - Keep it product-focused (no marketing fluff). - - If key fitment is missing, omit fitment entirely (do NOT guess). - - If finish is missing, omit finish entirely (do NOT invent). - - FINAL CHECK: - - JSON parses correctly. - - Length limits respected. - - No text outside JSON.` - - , - session_id: crypto.randomUUID(), - image_base64: null, - file_name: null, - file_base64: null - }; - - - // const seo_data_from_llm = await seo_llm_client.post(`/chat-json`, requestBody); - - // const rawReply = seo_data_from_llm.data.reply; - - - // let parsed = {}; - // try { - // const extracted = extractFirstJsonObject(rawReply); - // if (!extracted) throw new Error("No JSON object found in reply"); - // parsed = typeof extracted === "string" ? JSON.parse(extracted) : extracted; - // } catch (e) { - // console.error("Failed to parse SEO JSON:", e); - // parsed = { seo_title: "", seo_description: "" }; // fallback - // } - parsed = { seo_title: "", seo_description: "" }; - - const { seo_title, seo_description } = parsed; - - console.log("SEO TITLE FROM LLM -", seo_title); - console.log("SEO DESCRIPTION FROM LLM -", seo_description); - - - - - const SHOP = shop; - const ACCESS_TOKEN = accessToken; - const item = product; - const attrs = item.attributes; - - - - const globalUniqueFitmentMap = { - make: new Set(), - model: new Set(), - year: new Set(), - drive: new Set(), - baseModel: new Set() - }; - - // Loop over all processed items - - const tags = item.attributes?.fitmmentTags; - - for (const key in globalUniqueFitmentMap) { - if (tags[key]) { - tags[key].forEach(value => { - globalUniqueFitmentMap[key].add(value); - }); - } - } - - - // Convert sets to arrays - const convertedGlobalUniqueFitmentMap = {}; - for (const key in globalUniqueFitmentMap) { - convertedGlobalUniqueFitmentMap[key] = Array.from(globalUniqueFitmentMap[key]); - } - const fitmentTags = convertedGlobalUniqueFitmentMap; - - - const allFitmentTagsSet = new Set(); - for (const arr of Object.values(convertedGlobalUniqueFitmentMap)) { - arr.forEach(val => allFitmentTagsSet.add(val)); - } - const allFitmentTags = Array.from(allFitmentTagsSet); - // Now allFitmentTags is a flat array of unique values - - log(shop, `All Fitment Tags for ${attrs.product_name || attrs.part_number}: ${JSON.stringify(allFitmentTags, null, 2)}`); - - log(shop, `Fitment Tags for ${attrs.product_name || attrs.part_number}: ${JSON.stringify(fitmentTags, null, 2)}`); - - - - - - try { - - - var inventoryData = attrs.inventorydata.inventory - - const totalQuantity = Object.values(inventoryData).reduce((sum, val) => sum + val, 0); - - - //console.log(totalQuantity, "1234567890") - - - - const client = axios.create({ - // baseURL: `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, - baseURL: `https://${SHOP}/admin/api/2025-10/graphql.json`, - headers: { - 'X-Shopify-Access-Token': ACCESS_TOKEN, - 'Content-Type': 'application/json', - }, - }); - - const client_2024_01 = axios.create({ - // baseURL: `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, - baseURL: `https://${SHOP}/admin/api/2024-01/graphql.json`, - headers: { - 'X-Shopify-Access-Token': ACCESS_TOKEN, - 'Content-Type': 'application/json', - }, - }); - - - - - 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, ...allFitmentTags].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, - ...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()); - - // 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 handle = slugify(item.id) - // const handle = slugify(item.id + "-" + (attrs.mfr_part_number || attrs.product_name)) - - - const searchRes = await client.post('', { - query: ` - query { - products(first: 1, query: "handle:${handle}") { - nodes { id handle } + // 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 }, + }); - // console.log(`[AddProductToStore] Search result for handle "${handle}":`, searchRes.data.data.products); - - const exists = searchRes.data?.data?.products?.nodes?.length > 0; - if (exists) { - log(shop, `⏭️ [${procId}] Skipping duplicate product: ${attrs.part_number}`); - results.push({ skippedHandle: attrs.part_number, reason: "handle in use" }); - return null; - } else { - - 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 - } - } - } + await client.post('', { + query: ` + mutation InventorySet($input: InventorySetQuantitiesInput!) { + inventorySetQuantities(input: $input) { + inventoryAdjustmentGroup { createdAt reason } userErrors { field message } } } `, - variables: { - product: { - title: attrs.product_name, - descriptionHtml: descriptionHtml, - vendor: attrs.brand, - productType: attrs.category, - handle: handle, - 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; - console.log("Invemtory Item ID : ", inventoryItemId) - // Bulk-update variant (price, compare-at, barcode) - const baseprice = parseFloat(attrs.price) || 0; - - - - - 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 parsed = JSON.parse(pricingMf.value); - priceType = parsed.priceType || 'map'; - percentage = Number(parsed.percentage) || 0; - } catch (err) { - console.error('Failed to parse pricing_config metafield:', err); - } - } - - // 2) Apply your price calculation using the metafield values - let price = baseprice; - - if (priceType === 'percentage') { - price = baseprice + (baseprice * (percentage / 100)); - } - - - - log(shop, `📢 [${procId}] Calculated price: ${price} (type: ${priceType}, percentage: ${percentage})`); - - - - - const comparePrice = parseFloat(attrs.compare_price) || null; - const barcode = attrs.barcode || ""; - - log(shop, `💲 [${procId}] Updating pricing for variant: ${variantId}`); - - const weightValue = parseFloat(attrs.dimensions?.[0]?.weight) || 0; - - const bulkRes_new = 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: { + input: { + name: 'available', + reason: 'correction', + referenceDocumentUri: 'logistics://turn14.data4autos.com/inventory', + ignoreCompareQuantity: true, + quantities: [{ inventoryItemId, locationId, quantity: totalQuantity, compareQuantity: 1 }], + }, + }, + }); } - `, - variables: { - productId: product.id, - variants: [{ - id: variantId, - price, - ...(comparePrice !== null && { compareAtPrice: comparePrice }), - ...(barcode && { barcode }), - - - inventoryItem: { - sku: attrs.part_number, - measurement: { - weight: { value: weightValue, unit: "POUNDS" } - }, - // tracke d: true - } - }] - }, - }); - - - - // const bulkRes = await client.post('', { - // query: ` - // mutation UpdateProductVariant( - // $productId: ID!, - // $variants: [ProductVariantsBulkInput!]! - // ) { - // productVariantsBulkUpdate(productId: $productId, variants: $variants) { - // productVariants { - // id - // price - // compareAtPrice - // barcode - - // inventoryItem { - // measurement { - // weight { - // value - // unit - // } - // } - // tracked - // } - // } - // userErrors { - // field - // message - // } - // } - // } - // `, - // variables: { - // productId: product.id, - // variants: [{ - // id: variantId, - // price, - // ...(comparePrice !== null && { compareAtPrice: comparePrice }), - // ...(barcode && { barcode }), - - // // sku: attrs.part_number, - // inventoryItem: { - // measurement: { - // weight: { value: weightValue, unit: "POUNDS" } - // }, - // // tracked: true - // } - // }] - // }, - // }); - - - - - log(shop, `🔄 [${procId}] Bulk updating variant: ${variantId}`); - //console.warn(JSON.stringify(bulkRes.data, null, 2)) - const bulkJson = bulkRes_new.data; - 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; - - - - log(shop, `📦 [${procId}] Updating inventory for product`); - - const invRes = await client.post('', { - query: ` - mutation InventoryItemUpdate($id: ID!, $input: InventoryItemInput!) { - inventoryItemUpdate(id: $id, input: $input) { - inventoryItem { - id - sku - unitCost { - amount - currencyCode - } - tracked - measurement { - weight { - value - unit - } - } - } - userErrors { - field - message - } - } - } - `, - variables: { - id: inventoryItemId, - input: { - cost: parseFloat(attrs.purchase_cost) || 0, - tracked: true - } - } - }); - - const invJson = invRes.data; - //console.log(JSON.stringify(invJson, null, 2)) - const invErrs = invJson.data.inventoryItemUpdate.userErrors; - if (invErrs.length) { - throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`); - } - - - - const activateInventoryMutation = ` - mutation ActivateInventoryItem($inventoryItemId: ID!, $locationId: ID!) { - inventoryActivate(inventoryItemId: $inventoryItemId, locationId: $locationId) { - inventoryLevel { - id - quantities(names: ["available"]) { - name - quantity - } - item { id } - location { id } - } - userErrors { - field - message - } - } - } - `; - - const activateInventoryVariables = { - inventoryItemId: inventoryItemId, // your inventory item ID - locationId: locationId - }; - - const activateInventoryRes = await client.post('', { - query: activateInventoryMutation, - variables: activateInventoryVariables - }); - log('Activate Inventory:', JSON.stringify(activateInventoryRes.data, null, 2)); - - - - - - - - - - - - - - const mutation = ` - mutation InventorySet($input: InventorySetQuantitiesInput!) { - inventorySetQuantities(input: $input) { - inventoryAdjustmentGroup { - createdAt - reason - referenceDocumentUri - changes { - name - delta - } - } - userErrors { - field - message - } - } - } - `; - - const variables = { - input: { - name: "available", - reason: "correction", - referenceDocumentUri: "logistics://some.warehouse/take/2023-01-23T13:14:15Z", - ignoreCompareQuantity: true, - quantities: [ - { - inventoryItemId: inventoryItemId, - locationId: locationId, - quantity: totalQuantity, - compareQuantity: 1 - } - ] - } - }; - - - console.log("Variables for Setting Inventory : ", totalQuantity) - - var setInventoryRes - - - try { - console.log("newwww") - setInventoryRes = await client.post('', { - query: mutation, - variables: variables - }); - - } catch (error) { - if (error.setInventoryRes) { - console.error('Error:', error.setInventoryRes.data); - } else { - console.error('Error:', error.message); - } - } - - - - - - - - - - - - - const setInventoryData = setInventoryRes.data.inventorySetQuantities; - - if (setInventoryData?.userErrors.length) { - throw new Error( - "Inventory update errors: " + - setInventoryData?.userErrors.map(e => e.message).join(", ") - ); - } - - - - - // const updatedProduct = prodRes.data; - - - - - - const prodRes = await client.post('', { - query: ` + // SEO update + await client.post('', { + query: ` mutation ProductUpdate($product: ProductUpdateInput!) { productUpdate(product: $product) { - product { - id - title - seo { - title - description - } - } - userErrors { - field - message - } + product { id seo { title description } } + userErrors { field message } } } `, - variables: { - product: { - id: product.id, // e.g. "gid://shopify/Product/1234567890" - seo: { - title: seo_title || `${product.title} | Performance Auto Parts`, - description: - seo_description || - `Find high-quality ${product.title} built for reliability and performance. Trusted automotive brands and precision engineering.` - - } - // ...other fields as needed - } - } - }); - - const prodJson = prodRes.data; - const prodErrsseo = prodJson.data.productUpdate.userErrors; - if (prodErrs.length) { - throw new Error(`Product update errors: ${prodErrs.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, tokens, selectedProductIds, productCount) => { - const fulfillmentServiceTokens = tokens.fulfillmentService || {} - const fulfillmentServiceId = fulfillmentServiceTokens.id || null; - // const locationId = fulfillmentServiceTokens.location ? fulfillmentServiceTokens.location.id : null; - const locationId = tokens.locationId ? tokens.locationId : null; - console.log("Custom Location ID to Store : ", locationId) - log(shop, `🔍 [${procId}] Fetching products for brand ${brandId}`); - const products_res = await GetAllProductsOfBranch(brandId, turn14accessToken, shop, procId, productCount); - - const items = products_res ? products_res.items : []; - - - - // Update total products count - - - const results = []; - - const products_filter = items.filter(item => { - return selectedProductIds.includes(item.id); - }); - - - - const products = Array.isArray(products_filter) ? products_filter : []; - //const products = Array.isArray(products_filter) ? products_filter.slice(0, 11) : []; - - processes[procId].totalProducts = products.length; - processes[procId].processedProducts = 0; - log(shop, `🔄 [${procId}] Processing ${products.length} products after the filter`); - if (!products) { - log(shop, `⚠️ [${procId}] No products found for brand ${brandId}`); - return []; - } - - - 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, fulfillmentServiceId, locationId); - 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, productCount, selectedProductIds } = 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 - - - if (!turn14accessToken) throw new Error('No Turn14 access token provided'); - if (!brandID) throw new Error('No brand ID provided'); - if (!shop) throw new Error('No shop provided'); - if (!selectedProductIds) throw new Error('No selected product IDs provided'); - log(shop, `Selected Product IDs: ${selectedProductIds}`); - // console.log("Selected Product IDs:", selectedProductIds); - if (!Array.isArray(selectedProductIds) || selectedProductIds.length === 0) { - throw new Error('Selected product IDs must be a non-empty array'); - } - - - log(shop, `🔍 [${procId}] Fetching products for brand ${brandID}`); - - 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, tokenRecord, selectedProductIds, productCount); - - 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 + 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.`, + }, }, - results: info.results || [] - }; - - res.json(response); + }, }); - module.exports = router; \ No newline at end of file + + 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; diff --git a/server.js b/server.js index c413562..12b16f0 100755 --- a/server.js +++ b/server.js @@ -10,7 +10,8 @@ const manageProducts = require('./routes/manageProducts'); const managepricing = require('./routes/managePricing'); const privacyLawWebhooks = require('./routes/privacyLawWebhooks'); -const { getToken } = require('./tokenStore'); +const { getToken, listTokens } = require('./tokenStore'); +const { listJobs, getJob, cancelJob, getLatestJobForShop } = require('./jobStore'); const app = express(); const PORT = process.env.PORT || 3002; @@ -18,6 +19,45 @@ const PORT = process.env.PORT || 3002; // 0) CORS (safe before everything) app.use(cors()); +// Health check +app.get('/health', (req, res) => { + res.json({ ok: true, uptime: process.uptime(), timestamp: new Date().toISOString() }); +}); + +// Top-level job endpoints (mirrors manageproducts/jobs/* but at /jobs/*) +app.get('/jobs', (req, res) => { + const shop = req.query.shop || null; + res.json({ jobs: listJobs(shop) }); +}); + +app.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); +}); + +app.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 }); +}); + +app.get('/shops', (req, res) => { + try { + const store = listTokens(); + const shops = Object.keys(store).map(shop => ({ + shop, + savedAt: store[shop].savedAt, + hasToken: !!store[shop].accessToken, + hasLocation: !!store[shop].locationId, + hasFulfillment: !!store[shop].fulfillmentService, + })); + res.json({ shops }); + } catch { + res.json({ shops: [] }); + } +}); + app.get("/checkisshopdataexists/:shop", (req, res) => { const shop = req.params.shop; console.log("GET /checkisshopdataexists:", shop); diff --git a/tokenStore.js b/tokenStore.js index 23b0aff..9695bef 100755 --- a/tokenStore.js +++ b/tokenStore.js @@ -139,4 +139,8 @@ function getToken(shop) { return store[shop] || null; } -module.exports = { saveToken, getToken }; +function listTokens() { + return readStore(); +} + +module.exports = { saveToken, getToken, listTokens };