653 lines
19 KiB
JavaScript
Executable File
653 lines
19 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 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) => {
|
|
var AllProductsOfBrans = [];
|
|
|
|
try {
|
|
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
|
|
console.log("AllProductsOfBrans", AllProductsOfBrans.length);
|
|
const items = Array.isArray(AllProductsOfBrans) ? AllProductsOfBrans.slice(0, 5) : [];
|
|
console.log("items", items.length);
|
|
|
|
return items;
|
|
} catch (err) {
|
|
console.error("Error fetching items:", err);
|
|
return null
|
|
}
|
|
}
|
|
|
|
const AddProductToStore = async (shop, accessToken, product) => {
|
|
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',
|
|
},
|
|
});
|
|
|
|
|
|
try {
|
|
|
|
const attrs = item.attributes;
|
|
// console.log("Processing item:", attrs);
|
|
|
|
// 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))
|
|
);
|
|
// console.log("Collection Titles:", collectionTitles);
|
|
|
|
// Find or create collections, collect their IDs
|
|
const collectionIds = []
|
|
|
|
for (const title of collectionTitles) {
|
|
// console.log(`Searching for 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) {
|
|
console.log(`→ Found existing collection: ${existing[0].id}`);
|
|
collectionIds.push(existing[0].id);
|
|
continue;
|
|
}
|
|
|
|
// 2. Otherwise, create it
|
|
//console.log(`→ No existing collection. Creating "${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;
|
|
// console.log(`→ Created collection: ${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());
|
|
// console.log("Tags:", tags);
|
|
|
|
// 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}`,
|
|
}));
|
|
// console.log("Media inputs:", mediaInputs);
|
|
|
|
// 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;
|
|
// console.log("Description HTML:", descriptionHtml);
|
|
|
|
// Create product + attach to collections + add media
|
|
// const createProdRes = await admin.graphql(`
|
|
// mutation($prod: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
|
// productCreate(product: $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(attrs.part_number || attrs.product_name),
|
|
// tags,
|
|
// collectionsToJoin: collectionIds,
|
|
// status: "ACTIVE",
|
|
// },
|
|
// media: mediaInputs,
|
|
// },
|
|
// });
|
|
|
|
|
|
// const createProdRes = await client.post('', {
|
|
// query: `
|
|
// mutation($prod: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
|
// productCreate(product: $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;
|
|
// console.log("Create product response:", createProdJson);
|
|
|
|
// const prodErrs = createProdJson.data?.productCreate?.userErrors || [];
|
|
// if (prodErrs.length) {
|
|
// const taken = prodErrs.some(e => /already in use/i.test(e.message));
|
|
// if (taken) {
|
|
// 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 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;
|
|
//console.log("Create product response:", createProdJson);
|
|
|
|
const prodErrs = createProdJson.data?.productCreate?.userErrors || [];
|
|
if (prodErrs.length) {
|
|
const taken = prodErrs.some(e => /already in use/i.test(e.message));
|
|
if (taken) {
|
|
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) {
|
|
console.error("Variant node is undefined 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 || "";
|
|
|
|
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 bulkJson = bulkRes.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
|
|
//console.log("Fetching Online Store publication ID...");
|
|
const publicationsRes = await client.post('', {
|
|
query: `
|
|
query {
|
|
publications(first: 10) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
});
|
|
const publicationsJson = publicationsRes.data;
|
|
// console.log("Publications response:", publicationsJson);
|
|
|
|
const onlineStorePublication = publicationsJson.data.publications.edges.find(
|
|
pub => pub.node.name === 'Online Store'
|
|
);
|
|
const onlineStorePublicationId = onlineStorePublication ? onlineStorePublication.node.id : null;
|
|
if (onlineStorePublicationId) {
|
|
// console.log("Publishing product to Online Store...");
|
|
|
|
// ▶︎ Replace admin.graphql(…) with:
|
|
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;
|
|
console.log("Publish response:", publishJson);
|
|
|
|
const publishErrs = publishJson.data.publishablePublish.userErrors;
|
|
if (publishErrs.length) {
|
|
throw new Error(`Publish errors: ${publishErrs.map(e => e.message).join(", ")}`);
|
|
}
|
|
} else {
|
|
throw new Error("Online Store publication not found.");
|
|
}
|
|
|
|
|
|
// Update inventory item (SKU, cost & weight)
|
|
const costPerItem = parseFloat(attrs.purchase_cost) || 0;
|
|
const weightValue = parseFloat(attrs.dimensions?.[0]?.weight) || 0;
|
|
|
|
// console.log("Updating inventory item...");
|
|
// const invRes = await client.post('', {
|
|
// query: `
|
|
// mutation($id: ID!, $input: InventoryItemInput!) {
|
|
// inventoryItemUpdate(id: $id, input: $input) {
|
|
// inventoryItem {
|
|
// id
|
|
// sku
|
|
// measurement {
|
|
// weight { value unit }
|
|
// }
|
|
// }
|
|
// userErrors { field message }
|
|
// }
|
|
// }
|
|
// `,
|
|
// variables: {
|
|
// id: inventoryItemId,
|
|
// input: {
|
|
// sku: attrs.part_number,
|
|
// cost: parseFloat(attrs.purchase_cost) || 0,
|
|
// measurement: {
|
|
// weight: {
|
|
// value: parseFloat(attrs.dimensions?.[0]?.weight) || 0,
|
|
// unit: "POUNDS"
|
|
// }
|
|
// }
|
|
// }
|
|
// }
|
|
// });
|
|
|
|
// const invJson = invRes.data;
|
|
// console.log("Inventory update response:", invJson);
|
|
|
|
// const invErrs = invJson.data.inventoryItemUpdate.userErrors;
|
|
// if (invErrs.length) {
|
|
// throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`);
|
|
// }
|
|
// const inventoryItem = invJson.data.inventoryItemUpdate.inventoryItem;
|
|
|
|
|
|
// console.log("Updating inventory item...");
|
|
// const invRes = await client.post('', {
|
|
// query: `
|
|
// mutation($id: ID!, $input: InventoryItemUpdateInput!) {
|
|
// inventoryItemUpdate(id: $id, input: $input) {
|
|
// inventoryItem {
|
|
// id
|
|
// sku
|
|
// measurement {
|
|
// weight { value unit }
|
|
// }
|
|
// }
|
|
// userErrors { field message }
|
|
// }
|
|
// }
|
|
// `,
|
|
// variables: {
|
|
// id: inventoryItemId,
|
|
// input: {
|
|
// sku: attrs.part_number,
|
|
// cost: parseFloat(attrs.purchase_cost) || 0,
|
|
// measurement: {
|
|
// weight: {
|
|
// value: parseFloat(attrs.dimensions?.[0]?.weight) || 0,
|
|
// unit: "POUNDS"
|
|
// }
|
|
// }
|
|
// }
|
|
// }
|
|
// });
|
|
|
|
// const invJson = invRes.data;
|
|
// console.log("Inventory update response:", invJson);
|
|
|
|
// const invErrs = invJson.data.inventoryItemUpdate.userErrors;
|
|
// if (invErrs.length) {
|
|
// throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`);
|
|
// }
|
|
// const inventoryItem = invJson.data.inventoryItemUpdate.inventoryItem;
|
|
|
|
|
|
console.log("Updating inventory item...");
|
|
const invRes = await client.post('', {
|
|
query: `
|
|
mutation($id: ID!, $input: InventoryItemUpdateInput!) {
|
|
inventoryItemUpdate(id: $id, input: $input) {
|
|
inventoryItem {
|
|
id
|
|
unitCost { amount }
|
|
tracked
|
|
}
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
id: inventoryItemId,
|
|
input: {
|
|
cost: parseFloat(attrs.purchase_cost) || 0,
|
|
tracked: true
|
|
}
|
|
}
|
|
});
|
|
|
|
const invJson = invRes.data;
|
|
console.log("Inventory update response:", invJson);
|
|
|
|
const invErrs = invJson.data.inventoryItemUpdate.userErrors;
|
|
if (invErrs.length) {
|
|
throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`);
|
|
}
|
|
const inventoryItem = invJson.data.inventoryItemUpdate.inventoryItem;
|
|
|
|
|
|
|
|
|
|
|
|
// Collect results
|
|
results.push({
|
|
productId: product.id,
|
|
variant: {
|
|
id: variantId,
|
|
price: variantNode.price,
|
|
compareAtPrice: variantNode.compareAtPrice,
|
|
sku: inventoryItem.sku,
|
|
barcode: variantNode.barcode,
|
|
weight: inventoryItem?.measurement?.weight.value,
|
|
weightUnit: inventoryItem?.measurement?.weight.unit,
|
|
},
|
|
collections: collectionTitles,
|
|
tags,
|
|
});
|
|
|
|
return results;
|
|
|
|
|
|
|
|
} catch (err) {
|
|
console.error("Error in AddProductToStore:", err);
|
|
results.push({ error: `Failed to process item ${item.id}: ${err.message}` });
|
|
return results;
|
|
}
|
|
}
|
|
|
|
const GetAllProductsandAddToStore = async (shop, accessToken, brandId, turn14accessToken) => {
|
|
const products = await GetAllProductsOfBranch(brandId, turn14accessToken);
|
|
if (!products) {
|
|
console.error("No products found or error fetching products.");
|
|
return [];
|
|
}
|
|
|
|
const results = [];
|
|
for (const item of products) {
|
|
const res = await AddProductToStore(shop, accessToken, item);
|
|
results.push(...res);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function fetchAllCollections(shop, accessToken) {
|
|
const adminUrl = `https://${shop}/admin/api/2023-10/graphql.json`;
|
|
const headers = {
|
|
'X-Shopify-Access-Token': accessToken,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
let allCollections = [];
|
|
let hasNextPage = true;
|
|
let endCursor = null;
|
|
const pageSize = 100;
|
|
|
|
while (hasNextPage) {
|
|
const fetchQuery = `
|
|
query GetCollections {
|
|
collections(first: ${pageSize}${endCursor ? `, after: "${endCursor}"` : ''}) {
|
|
edges {
|
|
node {
|
|
id
|
|
title
|
|
}
|
|
cursor
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const fetchResp = await axios.post(adminUrl, { query: fetchQuery }, { headers });
|
|
const collections = fetchResp.data.data.collections.edges.map(e => e.node);
|
|
allCollections = allCollections.concat(collections);
|
|
|
|
hasNextPage = fetchResp.data.data.collections.pageInfo.hasNextPage;
|
|
endCursor = fetchResp.data.data.collections.pageInfo.endCursor;
|
|
}
|
|
|
|
return allCollections;
|
|
}
|
|
|
|
|
|
|
|
router.post('/', async (req, res) => {
|
|
const { shop, brandID, turn14accessToken } = req.body;
|
|
|
|
const procId = uuid();
|
|
processes[procId] = { status: 'started', detail: null };
|
|
|
|
res.json({ processId: procId, status: 'started' });
|
|
|
|
(async () => {
|
|
try {
|
|
log(shop, `🔔 [${procId}] ManageBrands started`);
|
|
processes[procId].status = 'fetching_collections';
|
|
|
|
// 1. Get token
|
|
const tokenRecord = getToken(shop);
|
|
if (!tokenRecord) throw new Error('No token for shop');
|
|
|
|
|
|
|
|
const allCollections = await GetAllProductsandAddToStore(shop, tokenRecord.accessToken, brandID, turn14accessToken);
|
|
log(shop, `🔍 [${procId}] Fetchedd ${allCollections.length} existing collections`);
|
|
|
|
|
|
} 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' });
|
|
res.json(info);
|
|
});
|
|
|
|
module.exports = router;
|