1110 lines
35 KiB
JavaScript
Executable File
1110 lines
35 KiB
JavaScript
Executable File
// 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();
|
||
|
||
const seo_llm_client = axios.create({
|
||
baseURL: "https://llm.thedomainnest.com", // 👈 change this
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
|
||
},
|
||
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 GetAllProductsOfBranch = async (brandId, turn14accessToken, shop, procId, productCount) => {
|
||
var AllProductsOfBrans = [];
|
||
|
||
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 items = Array.isArray(AllProductsOfBrans) ? AllProductsOfBrans : [];
|
||
log(shop, `📝 [${procId}] Processing ${items.length} sample products`);
|
||
|
||
return { items, fitmentTags };
|
||
} catch (err) {
|
||
log(shop, `❌ [${procId}] Error fetching items: ${err.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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 }
|
||
}
|
||
}
|
||
|
||
`
|
||
});
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
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: {
|
||
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: `
|
||
mutation ProductUpdate($product: ProductUpdateInput!) {
|
||
productUpdate(product: $product) {
|
||
product {
|
||
id
|
||
title
|
||
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
|
||
},
|
||
results: info.results || []
|
||
};
|
||
|
||
res.json(response);
|
||
});
|
||
module.exports = router; |