From e87bd907eaae44d01d455cd9d5477b1122115eb3 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Mon, 13 Apr 2026 17:31:26 +0530 Subject: [PATCH] first commit --- .gitignore | 16 + auth.js | 80 + data/99_run_logs/2026-04-13T05-47-05-170Z.log | 2 + data/99_run_logs/2026-04-13T05-47-05-267Z.log | 2 + data/99_run_logs/2026-04-13T11-54-39-523Z.log | 2 + data/tokens.json | 1 + data/watermark.png | Bin 0 -> 11555 bytes fulfillmentService.js | 134 ++ logger.js | 21 + logs/general.log | 2 + logs/master.log | 2 + package-lock.json | 1614 +++++++++++++++++ package.json | 19 + routes/pipeline.js | 55 + routes/privacyLawWebhooks.js | 56 + server.js | 69 + src/business-logic/kyt-pipeline/00_index.js | 496 +++++ .../01_get_kytindia_website_data.js | 479 +++++ .../02_download_product_images.js | 220 +++ .../03_watermark_downloaded_images.js | 250 +++ .../04_shopify_image_file_uploader.js | 436 +++++ .../05_kyt_to_shopify_converter.js | 213 +++ .../kyt-pipeline/06_shopify_product_upsert.js | 595 ++++++ src/pipelineJobs.js | 64 + src/runKytPipelineJob.js | 84 + tokenStore.js | 63 + 26 files changed, 4975 insertions(+) create mode 100644 .gitignore create mode 100644 auth.js create mode 100644 data/99_run_logs/2026-04-13T05-47-05-170Z.log create mode 100644 data/99_run_logs/2026-04-13T05-47-05-267Z.log create mode 100644 data/99_run_logs/2026-04-13T11-54-39-523Z.log create mode 100644 data/tokens.json create mode 100644 data/watermark.png create mode 100644 fulfillmentService.js create mode 100644 logger.js create mode 100644 logs/general.log create mode 100644 logs/master.log create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 routes/pipeline.js create mode 100644 routes/privacyLawWebhooks.js create mode 100644 server.js create mode 100644 src/business-logic/kyt-pipeline/00_index.js create mode 100644 src/business-logic/kyt-pipeline/01_get_kytindia_website_data.js create mode 100644 src/business-logic/kyt-pipeline/02_download_product_images.js create mode 100644 src/business-logic/kyt-pipeline/03_watermark_downloaded_images.js create mode 100644 src/business-logic/kyt-pipeline/04_shopify_image_file_uploader.js create mode 100644 src/business-logic/kyt-pipeline/05_kyt_to_shopify_converter.js create mode 100644 src/business-logic/kyt-pipeline/06_shopify_product_upsert.js create mode 100644 src/pipelineJobs.js create mode 100644 src/runKytPipelineJob.js create mode 100644 tokenStore.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..477123c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +@" +node_modules/ +.env +.env.* +dist/ +build/ +.next/ +coverage/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +Thumbs.db +.vscode/ +.idea/ +"@ | Out-File -Encoding utf8 .gitignore \ No newline at end of file diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..4818c64 --- /dev/null +++ b/auth.js @@ -0,0 +1,80 @@ +const express = require("express"); +const axios = require("axios"); +const { log } = require("./logger"); +const { saveToken, deleteToken } = require("./tokenStore"); +const { createFulfillmentService } = require("./fulfillmentService"); + +const router = express.Router(); + +const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID; +const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET; + +router.get("/auth/login", (req, res) => { + const { shop } = req.query; + + if (!shop) { + return res.status(400).json({ error: "Missing shop query parameter." }); + } + + const redirectUri = process.env.SHOPIFY_REDIRECT_URI || `${process.env.APP_URL || "http://localhost:3002"}/auth/callback`; + const scopes = process.env.SHOPIFY_SCOPES || "write_products,write_files,write_inventory,write_publications"; + const installUrl = `https://${shop}/admin/oauth/authorize?client_id=${CLIENT_ID}&scope=${encodeURIComponent(scopes)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + + return res.redirect(installUrl); +}); + +router.get("/auth/callback", async (req, res) => { + const { shop, code } = req.query; + + if (!shop || !code) { + log("general", `Missing shop or code in callback: ${JSON.stringify(req.query)}`); + return res.status(400).send("Missing shop or code parameter."); + } + + try { + log(shop, "Exchanging OAuth code for access token"); + const resp = await axios.post( + `https://${shop}/admin/oauth/access_token`, + { + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + }, + { + headers: { "Content-Type": "application/json" }, + } + ); + + const { access_token, scope } = resp.data; + saveToken(shop, access_token, scope); + log(shop, "Token saved to data/tokens.json"); + + const fulfillment = await createFulfillmentService(shop, access_token); + if (fulfillment?.success) { + saveToken(shop, access_token, scope, fulfillment.fulfillmentService, fulfillment.locationId); + log(shop, "Fulfillment service and location stored"); + } else { + log(shop, `Fulfillment setup skipped/failed: ${JSON.stringify(fulfillment?.errors || fulfillment?.error || null)}`); + } + + const redirectTarget = process.env.SHOPIFY_AFTER_AUTH_REDIRECT || "https://admin.shopify.com"; + return res.redirect(redirectTarget); + } catch (err) { + const errMsg = err.response?.data || err.message; + log(shop, `OAuth error: ${JSON.stringify(errMsg)}`); + return res.status(500).send("Failed to get access token"); + } +}); + +router.post("/auth/logout", (req, res) => { + const { shop } = req.body || {}; + if (!shop) { + return res.status(400).json({ error: "Missing shop." }); + } + + deleteToken(shop); + log(shop, "Shop token removed"); + return res.json({ ok: true, shop }); +}); + +module.exports = router; diff --git a/data/99_run_logs/2026-04-13T05-47-05-170Z.log b/data/99_run_logs/2026-04-13T05-47-05-170Z.log new file mode 100644 index 0000000..233f3d8 --- /dev/null +++ b/data/99_run_logs/2026-04-13T05-47-05-170Z.log @@ -0,0 +1,2 @@ +[2026-04-13T05:47:05.171Z] [LOG] [RUN-LOG] Writing logs to D:\2026\Race-Nation-Shopify-Backend\Race-Nation-Shopify-App-Backend\data\99_run_logs\2026-04-13T05-47-05-170Z.log +[2026-04-13T05:47:05.171Z] [LOG] function diff --git a/data/99_run_logs/2026-04-13T05-47-05-267Z.log b/data/99_run_logs/2026-04-13T05-47-05-267Z.log new file mode 100644 index 0000000..b9f5da5 --- /dev/null +++ b/data/99_run_logs/2026-04-13T05-47-05-267Z.log @@ -0,0 +1,2 @@ +[2026-04-13T05:47:05.267Z] [LOG] [RUN-LOG] Writing logs to D:\2026\Race-Nation-Shopify-Backend\Race-Nation-Shopify-App-Backend\data\99_run_logs\2026-04-13T05-47-05-267Z.log +[2026-04-13T05:47:05.272Z] [LOG] [2026-04-13T05:47:05.271Z] [general] Server listening on port 3002 diff --git a/data/99_run_logs/2026-04-13T11-54-39-523Z.log b/data/99_run_logs/2026-04-13T11-54-39-523Z.log new file mode 100644 index 0000000..b164af6 --- /dev/null +++ b/data/99_run_logs/2026-04-13T11-54-39-523Z.log @@ -0,0 +1,2 @@ +[2026-04-13T11:54:39.523Z] [LOG] [RUN-LOG] Writing logs to D:\2026\Race-Nation-Shopify-Backend\Race-Nation-Shopify-App-Backend\data\99_run_logs\2026-04-13T11-54-39-523Z.log +[2026-04-13T11:54:39.527Z] [LOG] [2026-04-13T11:54:39.527Z] [general] Server listening on port 3002 diff --git a/data/tokens.json b/data/tokens.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/data/tokens.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/data/watermark.png b/data/watermark.png new file mode 100644 index 0000000000000000000000000000000000000000..4187a6bac4ba426273ee65e67551a51c7a095637 GIT binary patch literal 11555 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 Nb!8o;`j=K8{s&D|_YnX9 literal 0 HcmV?d00001 diff --git a/fulfillmentService.js b/fulfillmentService.js new file mode 100644 index 0000000..18bd1b8 --- /dev/null +++ b/fulfillmentService.js @@ -0,0 +1,134 @@ +const axios = require("axios"); +const { log } = require("./logger"); + +const getLocationQuery = ` + query { + locations(first: 1, query: "name:'Shop location'") { + nodes { + id + name + address { + address1 + address2 + city + province + provinceCode + country + countryCode + zip + phone + } + } + } + } +`; + +async function getStoreAddress(client) { + const response = await client.post("", { query: getLocationQuery }); + const location = response.data?.data?.locations?.nodes?.[0]; + return location?.address || null; +} + +function createLocationMutation(address) { + return ` + mutation { + locationAdd(input: { + name: "(App) Race Nation Distribution" + address: { + address1: "${address?.address1 || ""}" + address2: "${address?.address2 || ""}" + city: "${address?.city || ""}" + provinceCode: "${address?.provinceCode || "ON"}" + countryCode: ${JSON.stringify(address?.countryCode || "US")} + zip: "${address?.zip || ""}" + phone: "${address?.phone || ""}" + } + fulfillsOnlineOrders: true + }) { + location { + id + name + } + userErrors { + code + field + message + } + } + } + `; +} + +async function createCustomLocation(address, client) { + const response = await client.post("", { query: createLocationMutation(address) }); + return response.data?.data?.locationAdd || { location: null, userErrors: [] }; +} + +async function createFulfillmentService(shop, accessToken) { + const client = axios.create({ + baseURL: `https://${shop}/admin/api/2025-10/graphql.json`, + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + }); + + const mutation = ` + mutation { + fulfillmentServiceCreate( + name: "Race Nation Distribution" + callbackUrl: "https://backend.race-nation.com/fulfillment" + ) { + fulfillmentService { + id + serviceName + callbackUrl + handle + location { + id + } + } + userErrors { + field + message + } + } + } + `; + + try { + log(shop, "Creating fulfillment service..."); + const response = await client.post("", { query: mutation }); + const data = response.data?.data?.fulfillmentServiceCreate; + + if (data?.userErrors?.length) { + return { success: false, errors: data.userErrors }; + } + + const address = await getStoreAddress(client); + let locationId = data?.fulfillmentService?.location?.id || null; + + if (address) { + const customLocation = await createCustomLocation(address, client); + if (!customLocation?.userErrors?.length && customLocation?.location?.id) { + locationId = customLocation.location.id; + } + } + + return { + success: true, + fulfillmentService: data?.fulfillmentService || null, + locationId, + }; + } catch (error) { + log(shop, `Fulfillment service request failed: ${error.response ? JSON.stringify(error.response.data) : error.message}`); + return { + success: false, + error: error.response ? error.response.data : error.message, + }; + } +} + +module.exports = { + createFulfillmentService, +}; diff --git a/logger.js b/logger.js new file mode 100644 index 0000000..5e805c3 --- /dev/null +++ b/logger.js @@ -0,0 +1,21 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const logsDir = path.resolve(__dirname, "logs"); +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +const masterLogFile = path.join(logsDir, "master.log"); + +function log(scope, message) { + const line = `[${new Date().toISOString()}] [${scope}] ${message}\n`; + fs.appendFileSync(masterLogFile, line, "utf8"); + const scopeFile = path.join(logsDir, `${String(scope || "general").replace(/\W+/g, "_")}.log`); + fs.appendFileSync(scopeFile, line, "utf8"); + console.log(line.trim()); +} + +module.exports = { + log, +}; diff --git a/logs/general.log b/logs/general.log new file mode 100644 index 0000000..e87b496 --- /dev/null +++ b/logs/general.log @@ -0,0 +1,2 @@ +[2026-04-13T05:47:05.271Z] [general] Server listening on port 3002 +[2026-04-13T11:54:39.527Z] [general] Server listening on port 3002 diff --git a/logs/master.log b/logs/master.log new file mode 100644 index 0000000..e87b496 --- /dev/null +++ b/logs/master.log @@ -0,0 +1,2 @@ +[2026-04-13T05:47:05.271Z] [general] Server listening on port 3002 +[2026-04-13T11:54:39.527Z] [general] Server listening on port 3002 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c854b53 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1614 @@ +{ + "name": "race-nation-shopify-app-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "race-nation-shopify-app-backend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.13.2", + "cors": "^2.8.5", + "dotenv": "^17.2.0", + "express": "^5.1.0", + "sharp": "^0.33.5", + "uuid": "^11.1.0", + "xlsx": "^0.18.5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..58c1cef --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "race-nation-shopify-app-backend", + "version": "1.0.0", + "private": true, + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "axios": "^1.13.2", + "cors": "^2.8.5", + "dotenv": "^17.2.0", + "express": "^5.1.0", + "sharp": "^0.33.5", + "uuid": "^11.1.0", + "xlsx": "^0.18.5" + } +} diff --git a/routes/pipeline.js b/routes/pipeline.js new file mode 100644 index 0000000..c0998f8 --- /dev/null +++ b/routes/pipeline.js @@ -0,0 +1,55 @@ +const express = require("express"); +const { createJob, updateJob, getJob, listJobs, canStartJob } = require("../src/pipelineJobs"); +const { runKytPipelineJob } = require("../src/runKytPipelineJob"); +const { getToken } = require("../tokenStore"); + +const router = express.Router(); + +router.post("/run", async (req, res) => { + const { shop, limit = null } = req.body || {}; + + if (!shop) { + return res.status(400).json({ error: "Missing shop." }); + } + + if (!getToken(shop)) { + return res.status(400).json({ error: "No stored Shopify token found for this shop. Complete app auth first." }); + } + + if (!canStartJob(shop)) { + return res.status(409).json({ error: `A KYT pipeline job is already running for ${shop}.` }); + } + + const job = createJob({ shop, limit }); + updateJob(job.id, { + status: "queued", + step: "queued", + detail: "Job queued", + }); + + setImmediate(() => { + runKytPipelineJob(job); + }); + + return res.json({ + jobId: job.id, + status: "queued", + shop, + limit, + }); +}); + +router.get("/status/:jobId", (req, res) => { + const job = getJob(req.params.jobId); + if (!job) { + return res.status(404).json({ error: "Job not found." }); + } + + return res.json(job); +}); + +router.get("/jobs", (req, res) => { + return res.json({ jobs: listJobs() }); +}); + +module.exports = router; diff --git a/routes/privacyLawWebhooks.js b/routes/privacyLawWebhooks.js new file mode 100644 index 0000000..ba8df6c --- /dev/null +++ b/routes/privacyLawWebhooks.js @@ -0,0 +1,56 @@ +require("dotenv").config(); +const express = require("express"); +const crypto = require("crypto"); + +const router = express.Router(); + +router.use(express.raw({ type: "*/*" })); + +const SHOPIFY_API_SECRET = process.env.SHOPIFY_API_SECRET; + +function verifyHmac(rawBody, hmacHeader) { + if (!SHOPIFY_API_SECRET || !hmacHeader || !rawBody) { + return false; + } + + const digest = crypto + .createHmac("sha256", SHOPIFY_API_SECRET) + .update(rawBody) + .digest("base64"); + + const generated = Buffer.from(digest, "utf8"); + const received = Buffer.from(hmacHeader, "utf8"); + if (generated.length !== received.length) { + return false; + } + + return crypto.timingSafeEqual(generated, received); +} + +function parseJsonSafe(buf) { + try { + return JSON.parse(buf.toString("utf8")); + } catch { + return null; + } +} + +function handleWebhook(req, res, topicName) { + const hmacHeader = req.header("x-shopify-hmac-sha256"); + const shop = req.header("x-shopify-shop-domain"); + const topic = req.header("x-shopify-topic") || topicName; + + if (!verifyHmac(req.body, hmacHeader)) { + return res.status(401).send("Invalid HMAC"); + } + + const payload = parseJsonSafe(req.body) || {}; + console.log(`[WEBHOOK:${topic}] shop=${shop}`, payload); + return res.status(200).json({ status: "ok", topic, shop, received: payload }); +} + +router.post("/customers/data_request", (req, res) => handleWebhook(req, res, "customers/data_request")); +router.post("/customers/redact", (req, res) => handleWebhook(req, res, "customers/redact")); +router.post("/shop/redact", (req, res) => handleWebhook(req, res, "shop/redact")); + +module.exports = router; diff --git a/server.js b/server.js new file mode 100644 index 0000000..cb877a0 --- /dev/null +++ b/server.js @@ -0,0 +1,69 @@ +require("dotenv").config(); +const express = require("express"); +const cors = require("cors"); +const { log } = require("./logger"); + +const auth = require("./auth"); +const privacyLawWebhooks = require("./routes/privacyLawWebhooks"); +const pipelineRoutes = require("./routes/pipeline"); +const { getToken, listTokens } = require("./tokenStore"); + +const app = express(); +const PORT = process.env.PORT || 3002; + +app.use(cors()); + +app.get("/health", (req, res) => { + res.json({ ok: true, service: "race-nation-shopify-app-backend" }); +}); + +app.get("/shops/:shop", (req, res) => { + const shop = req.params.shop; + const tokenRecord = getToken(shop); + + if (!tokenRecord) { + return res.status(404).json({ status: 0, message: "Shop not found" }); + } + + return res.json({ + status: 1, + shop, + fields: { + accessToken: tokenRecord.accessToken ? "present" : "missing", + scope: tokenRecord.scope ? "present" : "missing", + savedAt: tokenRecord.savedAt ? "present" : "missing", + locationId: tokenRecord.locationId ? "present" : "missing", + fulfillmentService: tokenRecord.fulfillmentService ? "present" : "missing", + }, + }); +}); + +app.get("/shops", (req, res) => { + res.json({ shops: listTokens() }); +}); + +app.use("/webhooks", privacyLawWebhooks); +app.use(express.json({ limit: "10mb" })); +app.use(express.urlencoded({ limit: "10mb", extended: true })); +app.use("/", auth); + +app.post("/fulfillment", (req, res) => { + console.log("POST /fulfillment:", req.body); + res.sendStatus(200); +}); + +app.use("/pipeline", pipelineRoutes); + +const server = app.listen(PORT, () => { + log("general", `Server listening on port ${PORT}`); +}); + +server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + console.error(`Port ${PORT} is already in use.`); + process.exit(1); + } + + console.error("Server error:", err); + process.exit(1); +}); diff --git a/src/business-logic/kyt-pipeline/00_index.js b/src/business-logic/kyt-pipeline/00_index.js new file mode 100644 index 0000000..3aea544 --- /dev/null +++ b/src/business-logic/kyt-pipeline/00_index.js @@ -0,0 +1,496 @@ +const { getKytIndiaWebsiteData } = require("./01_get_kytindia_website_data"); +const { downloadProductImagesFromAggregatedJson } = require("./02_download_product_images"); +const { applyWatermarkToDownloadedImages } = require("./03_watermark_downloaded_images"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const fsSync = require("node:fs"); +const { convertKytJsonToShopifyProducts } = require("./05_kyt_to_shopify_converter"); +const { upsertShopifyProductFull } = require("./06_shopify_product_upsert"); +const { uploadKytWatermarkedImagesToShopifyFiles } = require("./04_shopify_image_file_uploader"); + +const DEFAULT_AGGREGATED_JSON = "data/01_products_aggregated.json"; +const DEFAULT_SHOPIFY_READY_JSON = "data/05_shopify_products_ready.json"; +const DEFAULT_UPLOADED_MAP_JSON = "data/04_shopify_uploaded_images_map.json"; +const DEFAULT_LOGS_DIR = "data/99_run_logs"; + +function nowIsoLocal() { + const d = new Date(); + return d.toISOString(); +} + +function initRunLogger(logsDir = DEFAULT_LOGS_DIR) { + const absLogsDir = path.resolve(process.cwd(), logsDir); + fsSync.mkdirSync(absLogsDir, { recursive: true }); + + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const logPath = path.join(absLogsDir, `${stamp}.log`); + const stream = fsSync.createWriteStream(logPath, { flags: "a" }); + + const original = { + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console) + }; + + function writeLine(level, args) { + const text = args.map((a) => { + if (typeof a === "string") return a; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }).join(" "); + stream.write(`[${nowIsoLocal()}] [${level}] ${text}\n`); + } + + console.log = (...args) => { + writeLine("LOG", args); + original.log(...args); + }; + console.info = (...args) => { + writeLine("INFO", args); + original.info(...args); + }; + console.warn = (...args) => { + writeLine("WARN", args); + original.warn(...args); + }; + console.error = (...args) => { + writeLine("ERROR", args); + original.error(...args); + }; + + console.log(`[RUN-LOG] Writing logs to ${logPath}`); + return { logPath }; +} + +function loadDotEnvFile(filePath = ".env") { + const abs = path.resolve(process.cwd(), filePath); + if (!fsSync.existsSync(abs)) return; + + const raw = fsSync.readFileSync(abs, "utf8"); + const lines = raw.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (key && process.env[key] == null) { + process.env[key] = value; + } + } +} + +loadDotEnvFile(".env"); +const RUN_LOGGER = initRunLogger(DEFAULT_LOGS_DIR); + +function parseCliArgs(argv = process.argv.slice(2)) { + const out = { + limit: null + }; + + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if ((a === "--limit" || a === "-n") && argv[i + 1]) { + const n = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(n) && n > 0) out.limit = n; + } + } + + return out; +} + +async function buildLimitedAggregatedJson(inputPath, limit) { + const absInputPath = path.resolve(process.cwd(), inputPath); + const raw = await fs.readFile(absInputPath, "utf8"); + const parsed = JSON.parse(raw); + const products = Array.isArray(parsed?.products) ? parsed.products : []; + const limited = products.slice(0, limit); + + const outPath = path.resolve(process.cwd(), `data/01_products_aggregated.limit_${limit}.json`); + const payload = { + ...parsed, + generatedAt: new Date().toISOString(), + products: limited, + analysis: { + ...(parsed.analysis || {}), + totalProductsUnique: limited.length, + limitedFrom: products.length, + limitApplied: limit + } + }; + + await fs.writeFile(outPath, JSON.stringify(payload, null, 2), "utf8"); + return { + inputPath: absInputPath, + outputPath: outPath, + totalBefore: products.length, + totalAfter: limited.length + }; +} + +function readBooleanEnv(name, fallback = false) { + const val = process.env[name]; + if (val == null) return fallback; + return ["1", "true", "yes", "y", "on"].includes(String(val).toLowerCase()); +} + +async function convertAggregatedToShopifyReady({ + inputPath = DEFAULT_AGGREGATED_JSON, + outputPath = DEFAULT_SHOPIFY_READY_JSON, + uploadedImageMapPath = DEFAULT_UPLOADED_MAP_JSON, + imageBaseUrl = process.env.KYT_IMAGE_BASE_URL || "", + brand = process.env.SHOPIFY_BRAND || "KYT" +} = {}) { + const absInputPath = path.resolve(process.cwd(), inputPath); + const absOutputPath = path.resolve(process.cwd(), outputPath); + const absUploadedMapPath = path.resolve(process.cwd(), uploadedImageMapPath); + + const raw = await fs.readFile(absInputPath, "utf8"); + const parsed = JSON.parse(raw); + let uploadedImageMap = null; + try { + const mapRaw = await fs.readFile(absUploadedMapPath, "utf8"); + uploadedImageMap = JSON.parse(mapRaw); + } catch { + uploadedImageMap = null; + } + + const convertedProducts = convertKytJsonToShopifyProducts(parsed, { + imageBaseUrl, + brand, + uploadedImageMap + }); + + const payload = { + generatedAt: new Date().toISOString(), + sourceFile: absInputPath, + totalProducts: convertedProducts.length, + products: convertedProducts + }; + + await fs.mkdir(path.dirname(absOutputPath), { recursive: true }); + await fs.writeFile(absOutputPath, JSON.stringify(payload, null, 2), "utf8"); + + return { + inputPath: absInputPath, + outputPath: absOutputPath, + uploadedImageMapPath: absUploadedMapPath, + totalProducts: convertedProducts.length + }; +} + +async function upsertShopifyProductsFromConverted({ + convertedPath = DEFAULT_SHOPIFY_READY_JSON, + shop = process.env.SHOPIFY_SHOP, + accessToken = process.env.SHOPIFY_ACCESS_TOKEN, + locationId = process.env.SHOPIFY_LOCATION_ID || null, + enableSeo = readBooleanEnv("SHOPIFY_ENABLE_SEO", false), + apiVersion = process.env.SHOPIFY_API_VERSION || "2025-10" +} = {}) { + if (!shop) { + throw new Error("Missing SHOPIFY_SHOP for Shopify upsert stage."); + } + if (!accessToken) { + throw new Error("Missing SHOPIFY_ACCESS_TOKEN for Shopify upsert stage."); + } + + const absConvertedPath = path.resolve(process.cwd(), convertedPath); + const raw = await fs.readFile(absConvertedPath, "utf8"); + const parsed = JSON.parse(raw); + const products = Array.isArray(parsed?.products) ? parsed.products : []; + + const startedAtMs = Date.now(); + const metrics = { + sourcePath: absConvertedPath, + total: products.length, + processed: 0, + created: 0, + updated: 0, + failed: 0, + errors: [], + startedAt: new Date(startedAtMs).toISOString(), + finishedAt: null, + durationSeconds: 0, + successRate: 0 + }; + + for (let i = 0; i < products.length; i += 1) { + const product = products[i]; + const label = product?.attributes?.product_name || product?.id || `item-${i + 1}`; + + try { + const result = await upsertShopifyProductFull({ + shop, + accessToken, + product, + locationId, + enableSeo, + apiVersion + }); + + metrics.processed += 1; + if (result.action === "created") metrics.created += 1; + if (result.action === "updated") metrics.updated += 1; + + if ((i + 1) % 10 === 0 || i === products.length - 1) { + console.log( + `[SHOPIFY] ${i + 1}/${products.length} processed | created=${metrics.created} updated=${metrics.updated} failed=${metrics.failed}` + ); + } + } catch (error) { + metrics.processed += 1; + metrics.failed += 1; + metrics.errors.push({ + index: i + 1, + product: label, + error: error.message + }); + console.log(`[SHOPIFY-FAIL] ${label} -> ${error.message}`); + } + } + + const endedAtMs = Date.now(); + metrics.finishedAt = new Date(endedAtMs).toISOString(); + metrics.durationSeconds = Number(((endedAtMs - startedAtMs) / 1000).toFixed(2)); + metrics.successRate = metrics.total > 0 + ? Number((((metrics.total - metrics.failed) / metrics.total) * 100).toFixed(2)) + : 0; + + return metrics; +} + +async function runFullKytPipeline(options = {}) { + const cli = parseCliArgs(); + const onProgress = typeof options.onProgress === "function" ? options.onProgress : null; + const emitProgress = (stepIndex, stepKey, message) => { + if (onProgress) { + onProgress({ + stepIndex, + totalSteps: 6, + stepKey, + message + }); + } + }; + + emitProgress(1, "fetchWebsiteData", "Fetching KYT website data"); + console.log("[PIPELINE 1/6] Fetching KYT website data..."); + const dataSummary = await getKytIndiaWebsiteData(); + + let aggregatedPathForRun = DEFAULT_AGGREGATED_JSON; + let imagesDirForRun = "data/02_downloaded_product_images"; + let limitSummary = null; + + if (cli.limit) { + limitSummary = await buildLimitedAggregatedJson(DEFAULT_AGGREGATED_JSON, cli.limit); + aggregatedPathForRun = path.relative(process.cwd(), limitSummary.outputPath).replace(/\\/g, "/"); + imagesDirForRun = `data/02_downloaded_product_images.limit_${cli.limit}`; + console.log( + `[PIPELINE] Limit applied: first ${limitSummary.totalAfter} of ${limitSummary.totalBefore} products -> ${aggregatedPathForRun}` + ); + } + + emitProgress(2, "downloadImages", "Downloading product images"); + console.log("\n[PIPELINE 2/6] Downloading product images..."); + const downloadSummary = await downloadProductImagesFromAggregatedJson({ + jsonPath: aggregatedPathForRun, + outputDir: imagesDirForRun + }); + + emitProgress(3, "watermarkImages", "Applying watermark to downloaded images"); + console.log("\n[PIPELINE 3/6] Applying watermark in-place..."); + const watermarkPathForRun = process.env.WATERMARK_PATH || "data/watermark.png"; + let watermarkSummary; + const absWatermarkPath = path.resolve(process.cwd(), watermarkPathForRun); + if (!fsSync.existsSync(absWatermarkPath)) { + watermarkSummary = { + skipped: true, + reason: `Watermark file not found: ${absWatermarkPath}`, + imagesDir: path.resolve(process.cwd(), imagesDirForRun), + watermarkPath: absWatermarkPath, + totalImagesFound: 0, + processed: 0, + skippedCount: 0, + failed: 0 + }; + console.log(`[PIPELINE 3/6] Watermark stage skipped. File missing: ${absWatermarkPath}`); + } else { + const watermarkRequired = readBooleanEnv("WATERMARK_REQUIRED", false); + try { + watermarkSummary = await applyWatermarkToDownloadedImages({ + imagesDir: imagesDirForRun, + watermarkPath: watermarkPathForRun + }); + } catch (error) { + if (String(error?.message || "").includes("ENOENT") && !watermarkRequired) { + watermarkSummary = { + skipped: true, + reason: `Watermark stage failed with ENOENT and was skipped: ${error.message}`, + imagesDir: path.resolve(process.cwd(), imagesDirForRun), + watermarkPath: absWatermarkPath, + totalImagesFound: 0, + processed: 0, + skippedCount: 0, + failed: 0 + }; + console.log(`[PIPELINE 3/6] Watermark stage skipped after ENOENT: ${error.message}`); + } else { + throw error; + } + } + } + + emitProgress(4, "uploadImagesToShopifyFiles", "Uploading watermarked images to Shopify Files"); + console.log("\n[PIPELINE 4/6] Uploading watermarked images to Shopify Files..."); + const imageUploadEnabled = readBooleanEnv("SHOPIFY_ENABLE_IMAGE_UPLOAD", true); + const imageUploadRequired = readBooleanEnv("SHOPIFY_IMAGE_UPLOAD_REQUIRED", false); + let imageUploadSummary; + + if (!imageUploadEnabled) { + imageUploadSummary = { + skipped: true, + reason: "SHOPIFY_ENABLE_IMAGE_UPLOAD=false", + totalTasks: 0, + processed: 0, + uploaded: 0, + failed: 0 + }; + console.log("[PIPELINE 4/6] Image upload stage skipped by config."); + } else { + try { + imageUploadSummary = await uploadKytWatermarkedImagesToShopifyFiles({ + shop: process.env.SHOPIFY_SHOP, + accessToken: process.env.SHOPIFY_ACCESS_TOKEN, + apiVersion: process.env.SHOPIFY_API_VERSION || "2025-10", + aggregatedJsonPath: aggregatedPathForRun, + imagesDir: imagesDirForRun, + statePath: "data/04_shopify_image_upload_state.json", + mapPath: DEFAULT_UPLOADED_MAP_JSON + }); + } catch (error) { + const message = String(error?.message || "Unknown image upload error"); + imageUploadSummary = { + skipped: true, + reason: message, + totalTasks: 0, + processed: 0, + uploaded: 0, + failed: 0 + }; + + console.log(`[PIPELINE 4/6] Image upload stage failed: ${message}`); + if (imageUploadRequired) { + throw error; + } + console.log("[PIPELINE 4/6] Continuing pipeline without image upload (SHOPIFY_IMAGE_UPLOAD_REQUIRED=false)."); + } + } + + emitProgress(5, "convertToShopifyReady", "Converting KYT data to Shopify-ready products"); + console.log("\n[PIPELINE 5/6] Converting KYT data to Shopify-ready products..."); + const conversionSummary = await convertAggregatedToShopifyReady({ + inputPath: aggregatedPathForRun, + outputPath: DEFAULT_SHOPIFY_READY_JSON, + uploadedImageMapPath: DEFAULT_UPLOADED_MAP_JSON, + imageBaseUrl: process.env.KYT_IMAGE_BASE_URL || "", + brand: process.env.SHOPIFY_BRAND || "KYT" + }); + + emitProgress(6, "upsertToShopify", "Upserting products to Shopify"); + console.log("\n[PIPELINE 6/6] Upserting products to Shopify..."); + const shopifyUpsertSummary = await upsertShopifyProductsFromConverted({ + convertedPath: DEFAULT_SHOPIFY_READY_JSON, + shop: process.env.SHOPIFY_SHOP, + accessToken: process.env.SHOPIFY_ACCESS_TOKEN, + locationId: process.env.SHOPIFY_LOCATION_ID || null, + enableSeo: readBooleanEnv("SHOPIFY_ENABLE_SEO", false), + apiVersion: process.env.SHOPIFY_API_VERSION || "2025-10" + }); + + const summary = { + completedAt: new Date().toISOString(), + runLogPath: RUN_LOGGER.logPath, + limit: cli.limit || null, + limitSummary, + steps: { + fetchWebsiteData: dataSummary, + downloadImages: downloadSummary, + watermarkImages: watermarkSummary, + uploadImagesToShopifyFiles: imageUploadSummary, + convertToShopifyReady: conversionSummary, + upsertToShopify: shopifyUpsertSummary + }, + upcomingSteps: [] + }; + + emitProgress(6, "completed", "KYT pipeline completed"); + console.log("\n=== FULL PIPELINE SUMMARY ==="); + console.log(JSON.stringify(summary, null, 2)); + console.log("\n=== SUMMARY TABLE ==="); + console.table([ + { + step: "fetchWebsiteData", + total: dataSummary?.analysis?.totalProductsUnique ?? "", + processed: dataSummary?.analysis?.detailFetchedNow ?? "", + skipped: dataSummary?.analysis?.cachedDetailReused ?? "", + failed: dataSummary?.analysis?.detailFailed ?? "" + }, + { + step: "downloadImages", + total: downloadSummary?.totalImagesFound ?? "", + processed: downloadSummary?.downloaded ?? "", + skipped: downloadSummary?.skipped ?? "", + failed: downloadSummary?.failed ?? "" + }, + { + step: "watermarkImages", + total: watermarkSummary?.totalImagesFound ?? "", + processed: watermarkSummary?.processed ?? "", + skipped: watermarkSummary?.skipped ?? "", + failed: watermarkSummary?.failed ?? "" + }, + { + step: "uploadImagesToShopifyFiles", + total: imageUploadSummary?.totalTasks ?? "", + processed: imageUploadSummary?.processed ?? "", + skipped: imageUploadSummary?.skipped ?? "", + failed: imageUploadSummary?.failed ?? "" + }, + { + step: "convertToShopifyReady", + total: conversionSummary?.totalProducts ?? "", + processed: conversionSummary?.totalProducts ?? "", + skipped: "", + failed: "" + }, + { + step: "upsertToShopify", + total: shopifyUpsertSummary?.total ?? "", + processed: shopifyUpsertSummary?.processed ?? "", + skipped: "", + failed: shopifyUpsertSummary?.failed ?? "" + } + ]); + + return summary; +} + +module.exports = { + runFullKytPipeline, + convertAggregatedToShopifyReady, + upsertShopifyProductsFromConverted +}; + +if (require.main === module) { + runFullKytPipeline().catch((error) => { + console.error("Full pipeline failed:", error.message); + process.exitCode = 1; + }); +} diff --git a/src/business-logic/kyt-pipeline/01_get_kytindia_website_data.js b/src/business-logic/kyt-pipeline/01_get_kytindia_website_data.js new file mode 100644 index 0000000..1ca784d --- /dev/null +++ b/src/business-logic/kyt-pipeline/01_get_kytindia_website_data.js @@ -0,0 +1,479 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); +const XLSX = require("xlsx"); + +const BASE_URL = "https://kytindia.com/server/public/api/products"; +const DATA_DIR = "data"; +const OUT_JSON = path.join(DATA_DIR, "01_products_aggregated.json"); +const OUT_HISTORY_JSON = path.join(DATA_DIR, "01_products_run_history.json"); +const OUT_XLSX = path.join(DATA_DIR, "01_products_aggregated.xlsx"); + +const COMMON_HEADERS = { + accept: "application/json, text/plain, */*", + "accept-language": "en-GB,en-US;q=0.9,en;q=0.8,ta;q=0.7", + "cache-control": "no-cache", + "content-type": "application/json", + pragma: "no-cache", + priority: "u=1, i", + "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin" +}; + +const REQUEST_BASE = { + method: "POST", + mode: "cors", + credentials: "omit", + referrer: "https://kytindia.com/products/racing", + headers: COMMON_HEADERS +}; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function postJson(endpoint, body, extraHeaders = {}) { + const response = await fetch(`${BASE_URL}/${endpoint}`, { + ...REQUEST_BASE, + headers: { ...COMMON_HEADERS, ...extraHeaders }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const err = new Error(`HTTP ${response.status} ${response.statusText} for ${endpoint}`); + err.status = response.status; + + const retryAfter = response.headers.get("retry-after"); + if (retryAfter) { + err.retryAfterMs = Number.parseInt(retryAfter, 10) * 1000; + } + + throw err; + } + + return response.json(); +} + +async function postJsonWithRetry(endpoint, body, extraHeaders = {}, maxAttempts = 5) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + return await postJson(endpoint, body, extraHeaders); + } catch (error) { + const isRetriable = error?.status === 429 || (error?.status >= 500 && error?.status <= 599); + const isLast = attempt === maxAttempts; + + if (!isRetriable || isLast) { + throw error; + } + + const waitMs = error?.retryAfterMs || attempt * 1200; + console.log(`[RETRY] ${endpoint} attempt ${attempt}/${maxAttempts} failed. Waiting ${waitMs}ms...`); + await sleep(waitMs); + } + } + + throw new Error(`Retry loop failed unexpectedly for ${endpoint}`); +} + +function getProductsArray(payload) { + if (!payload || typeof payload !== "object") { + return []; + } + + if (Array.isArray(payload.data)) { + return payload.data; + } + + if (Array.isArray(payload.products)) { + return payload.products; + } + + if (payload.data && Array.isArray(payload.data.products)) { + return payload.data.products; + } + + return []; +} + +function getUniqueProducts(combinedProducts) { + const uniqueById = new Map(); + + for (const item of combinedProducts) { + const id = item?.product?.id; + const key = id || `${item.subCategory}::${item.modelSeries}::${item?.product?.name || "unknown"}`; + + if (!uniqueById.has(key)) { + uniqueById.set(key, item); + } + } + + return uniqueById; +} + +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (true) { + const current = nextIndex; + nextIndex += 1; + + if (current >= items.length) { + return; + } + + results[current] = await mapper(items[current], current); + } + } + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()); + await Promise.all(workers); + + return results; +} + +async function readHistory(filePath) { + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +async function readExistingAggregated(filePath) { + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return { products: [], generatedAt: null }; + } + return { + generatedAt: parsed.generatedAt || null, + products: Array.isArray(parsed.products) ? parsed.products : [] + }; + } catch { + return { products: [], generatedAt: null }; + } +} + +function getProductKeyFromListing(item) { + const id = item?.product?.id || ""; + if (id) { + return id; + } + return `${item?.subCategory || ""}::${item?.modelSeries || ""}::${item?.product?.name || "unknown"}`; +} + +function getProductKeyFromDetailed(item) { + const id = item?.productId || item?.productSummary?.id || ""; + if (id) { + return id; + } + return `${item?.subCategory || ""}::${item?.modelSeries || ""}::${item?.productSummary?.name || "unknown"}`; +} + +function normalizeImageList(images) { + if (!Array.isArray(images)) { + return []; + } + return images.map((x) => String(x || "")); +} + +function getListingFingerprint(item) { + const id = item?.product?.id || ""; + const name = item?.product?.name || ""; + const mrp = item?.product?.cost?.mrp ?? null; + const sortOrder = item?.product?.sort_order ?? null; + const images = normalizeImageList(item?.product?.img); + const subCategory = item?.subCategory || ""; + const modelSeries = item?.modelSeries || ""; + return JSON.stringify({ id, name, mrp, sortOrder, images, subCategory, modelSeries }); +} + +function getDetailedFingerprint(item) { + const id = item?.productId || item?.productSummary?.id || ""; + const name = item?.productSummary?.name || ""; + const mrp = item?.productSummary?.cost?.mrp ?? null; + const sortOrder = item?.productSummary?.sort_order ?? null; + const images = normalizeImageList(item?.productSummary?.img); + const subCategory = item?.subCategory || ""; + const modelSeries = item?.modelSeries || ""; + return JSON.stringify({ id, name, mrp, sortOrder, images, subCategory, modelSeries }); +} + +function buildExcelRows(aggregatedProducts) { + return aggregatedProducts.map((item) => { + const product = item.productSummary || {}; + const details = item.productDetails || {}; + + return { + product_id: item.productId || "", + sub_category_code: item.subCategory || "", + model_series_code: item.modelSeries || "", + product_name: product.name || "", + sub_category_name: product.sub_category_name || "", + model_series_name: product.model_series_name || "", + mrp: product?.cost?.mrp ?? "", + image_count: Array.isArray(product?.img) ? product.img.length : 0, + detail_servercode: details?.servercode || "", + detail_name: details?.data?.name || "", + detail_sort_order: details?.data?.sort_order ?? "", + detail_payload_json: JSON.stringify(details) + }; + }); +} + +function writeExcel(filePath, rows, analysis) { + const wb = XLSX.utils.book_new(); + + const productsSheet = XLSX.utils.json_to_sheet(rows); + XLSX.utils.book_append_sheet(wb, productsSheet, "Products"); + + const analysisRows = [ + { key: "timestamp", value: analysis.timestamp }, + { key: "totalSubCategories", value: analysis.totalSubCategories }, + { key: "totalModelSeriesChecked", value: analysis.totalModelSeriesChecked }, + { key: "successfulCalls", value: analysis.successfulCalls }, + { key: "failedCalls", value: analysis.failedCalls }, + { key: "totalProductsCollectedRaw", value: analysis.totalProductsCollectedRaw }, + { key: "totalProductsUnique", value: analysis.totalProductsUnique }, + { key: "detailSuccess", value: analysis.detailSuccess }, + { key: "detailFailed", value: analysis.detailFailed } + ]; + + const analysisSheet = XLSX.utils.json_to_sheet(analysisRows); + XLSX.utils.book_append_sheet(wb, analysisSheet, "Analysis"); + + XLSX.writeFile(wb, filePath); +} + +async function getKytIndiaWebsiteData() { + try { + const outputJsonPath = path.resolve(process.cwd(), OUT_JSON); + const outputDir = path.dirname(outputJsonPath); + await fs.mkdir(outputDir, { recursive: true }); + const existingAggregated = await readExistingAggregated(outputJsonPath); + console.log( + `[CACHE] Loaded ${existingAggregated.products.length} existing products from ${OUT_JSON}` + + (existingAggregated.generatedAt ? ` (generatedAt: ${existingAggregated.generatedAt})` : "") + ); + + console.log("[1/5] Fetching sidebar master data..."); + const sidebar = await postJsonWithRetry("get-sidebar-master-data", {}); + const subcategories = Array.isArray(sidebar?.data) ? sidebar.data : []; + + const allRuns = []; + const combinedProducts = []; + + console.log("[2/5] Fetching product lists by subcategory/model..."); + for (const subcategory of subcategories) { + const subCategoryCode = subcategory?.code; + const models = Array.isArray(subcategory?.models) ? subcategory.models : []; + + if (!subCategoryCode || models.length === 0) { + continue; + } + + for (const model of models) { + const modelSeries = model?.url; + if (!modelSeries) { + continue; + } + + const payload = { + sub_category: subCategoryCode, + model_series: modelSeries + }; + + try { + const productRes = await postJsonWithRetry("get-product_list-with-model_series", payload); + const products = getProductsArray(productRes); + + allRuns.push({ + subCategory: subCategoryCode, + modelSeries, + productsCount: products.length, + servercode: productRes?.servercode || "UNKNOWN" + }); + + for (const product of products) { + combinedProducts.push({ + subCategory: subCategoryCode, + modelSeries, + product + }); + } + + console.log(`[OK] ${subCategoryCode} / ${modelSeries} -> ${products.length} products`); + } catch (error) { + allRuns.push({ + subCategory: subCategoryCode, + modelSeries, + productsCount: 0, + servercode: "FAILED", + error: error.message + }); + + console.log(`[FAIL] ${subCategoryCode} / ${modelSeries} -> ${error.message}`); + } + } + } + + const uniqueById = getUniqueProducts(combinedProducts); + const uniqueProducts = Array.from(uniqueById.values()); + + const existingByKey = new Map(); + for (const item of existingAggregated.products) { + existingByKey.set(getProductKeyFromDetailed(item), item); + } + + const reusedProducts = []; + const productsToFetch = []; + + for (const item of uniqueProducts) { + const key = getProductKeyFromListing(item); + const cached = existingByKey.get(key); + if (!cached) { + productsToFetch.push(item); + continue; + } + + const currentFp = getListingFingerprint(item); + const cachedFp = getDetailedFingerprint(cached); + + if (currentFp === cachedFp) { + reusedProducts.push({ + ...cached, + productId: item?.product?.id || cached.productId || "", + subCategory: item.subCategory, + modelSeries: item.modelSeries, + productSummary: item.product + }); + } else { + productsToFetch.push(item); + } + } + + console.log( + `[3/5] Incremental sync: total unique=${uniqueProducts.length}, ` + + `reused=${reusedProducts.length}, toFetch=${productsToFetch.length}` + ); + + const fetchedDetailedProducts = await mapWithConcurrency(productsToFetch, 2, async (item, index) => { + const productId = item?.product?.id; + + if (!productId) { + return { + productId: "", + subCategory: item.subCategory, + modelSeries: item.modelSeries, + productSummary: item.product, + productDetails: { servercode: "FAILED", error: "Missing product id" } + }; + } + + try { + const details = await postJsonWithRetry( + "get-product-details-by-id", + { product_id: productId }, + { Referer: `https://kytindia.com/product/${productId}` }, + 6 + ); + + if ((index + 1) % 20 === 0 || index === productsToFetch.length - 1) { + console.log(`[DETAIL] ${index + 1}/${productsToFetch.length} completed`); + } + + return { + productId, + subCategory: item.subCategory, + modelSeries: item.modelSeries, + productSummary: item.product, + productDetails: details + }; + } catch (error) { + console.log(`[DETAIL-FAIL] ${productId} -> ${error.message}`); + return { + productId, + subCategory: item.subCategory, + modelSeries: item.modelSeries, + productSummary: item.product, + productDetails: { servercode: "FAILED", error: error.message } + }; + } + }); + + const detailedProducts = [...reusedProducts, ...fetchedDetailedProducts]; + + const detailSuccess = detailedProducts.filter((x) => x?.productDetails?.servercode !== "FAILED").length; + const detailFailed = detailedProducts.length - detailSuccess; + + const analysis = { + timestamp: new Date().toISOString(), + totalSubCategories: subcategories.length, + totalModelSeriesChecked: allRuns.length, + successfulCalls: allRuns.filter((x) => x.servercode !== "FAILED").length, + failedCalls: allRuns.filter((x) => x.servercode === "FAILED").length, + totalProductsCollectedRaw: combinedProducts.length, + totalProductsUnique: uniqueProducts.length, + detailSuccess, + detailFailed, + cachedDetailReused: reusedProducts.length, + detailFetchedNow: fetchedDetailedProducts.length, + perModelCounts: allRuns + }; + + const finalPayload = { + generatedAt: analysis.timestamp, + analysis, + products: detailedProducts + }; + + console.log("[4/5] Writing JSON outputs..."); + await fs.writeFile(outputJsonPath, JSON.stringify(finalPayload, null, 2), "utf8"); + + const historyPath = path.resolve(process.cwd(), OUT_HISTORY_JSON); + const history = await readHistory(historyPath); + history.push(analysis); + await fs.writeFile(historyPath, JSON.stringify(history, null, 2), "utf8"); + + console.log("[5/5] Writing Excel output..."); + const excelRows = buildExcelRows(detailedProducts); + const excelPath = path.resolve(process.cwd(), OUT_XLSX); + writeExcel(excelPath, excelRows, analysis); + + console.log("\n=== FINAL ANALYSIS ==="); + console.log(JSON.stringify(analysis, null, 2)); + + console.log("\nSaved files:"); + console.log(`- ${outputJsonPath}`); + console.log(`- ${historyPath}`); + console.log(`- ${excelPath}`); + + return { + analysis, + outputJsonPath, + historyPath, + excelPath + }; + } catch (error) { + console.error("Pipeline failed:", error.message); + throw error; + } +} + +module.exports = { + getKytIndiaWebsiteData +}; + +if (require.main === module) { + getKytIndiaWebsiteData().catch(() => { + process.exitCode = 1; + }); +} diff --git a/src/business-logic/kyt-pipeline/02_download_product_images.js b/src/business-logic/kyt-pipeline/02_download_product_images.js new file mode 100644 index 0000000..b55db9b --- /dev/null +++ b/src/business-logic/kyt-pipeline/02_download_product_images.js @@ -0,0 +1,220 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); + +const DEFAULT_JSON_PATH = path.join("data", "01_products_aggregated.json"); +const DEFAULT_OUTPUT_DIR = path.join("data", "02_downloaded_product_images"); +const DEFAULT_BASE_IMAGE_URL = "https://kytindia.com/server/public/uploads"; + +function sanitizeName(value) { + return String(value || "") + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, " ") + .trim() + .slice(0, 150); +} + +function encodePathKeepingSlashes(relativePath) { + return String(relativePath || "") + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function getExtFromPathOrType(imagePath, contentType) { + const parsed = path.extname(imagePath || "").toLowerCase(); + if (parsed) { + return parsed; + } + + if (!contentType) { + return ".jpg"; + } + + if (contentType.includes("png")) return ".png"; + if (contentType.includes("webp")) return ".webp"; + if (contentType.includes("gif")) return ".gif"; + if (contentType.includes("jpeg") || contentType.includes("jpg")) return ".jpg"; + + return ".jpg"; +} + +async function downloadWithRetry(url, retries = 3) { + let lastError; + + for (let attempt = 1; attempt <= retries; attempt += 1) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + return { buffer, contentType: response.headers.get("content-type") || "" }; + } catch (error) { + lastError = error; + if (attempt < retries) { + await new Promise((resolve) => setTimeout(resolve, attempt * 500)); + } + } + } + + throw lastError; +} + +async function mapWithConcurrency(items, concurrency, worker) { + const results = new Array(items.length); + let index = 0; + + async function runWorker() { + while (true) { + const current = index; + index += 1; + + if (current >= items.length) { + return; + } + + results[current] = await worker(items[current], current); + } + } + + const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker()); + await Promise.all(workers); + + return results; +} + +async function downloadProductImagesFromAggregatedJson(options = {}) { + const { + jsonPath = DEFAULT_JSON_PATH, + outputDir = DEFAULT_OUTPUT_DIR, + baseImageUrl = DEFAULT_BASE_IMAGE_URL, + concurrency = 8, + skipExisting = true + } = options; + + const absJsonPath = path.resolve(process.cwd(), jsonPath); + const absOutputDir = path.resolve(process.cwd(), outputDir); + + const raw = await fs.readFile(absJsonPath, "utf8"); + const parsed = JSON.parse(raw); + const products = Array.isArray(parsed?.products) ? parsed.products : []; + + await fs.mkdir(absOutputDir, { recursive: true }); + + const usedFolderNames = new Set(); + const tasks = []; + + for (let i = 0; i < products.length; i += 1) { + const product = products[i] || {}; + const productId = product.productId || `unknown-${i + 1}`; + const productNameRaw = + product?.productSummary?.name || + product?.productDetails?.data?.name || + `product-${i + 1}`; + + let folderName = sanitizeName(productNameRaw) || `product-${i + 1}`; + if (usedFolderNames.has(folderName)) { + folderName = `${folderName}__${String(productId).slice(0, 8)}`; + } + usedFolderNames.add(folderName); + + const productDir = path.join(absOutputDir, folderName); + const imagePaths = Array.isArray(product?.productSummary?.img) + ? product.productSummary.img + : Array.isArray(product?.productDetails?.data?.img) + ? product.productDetails.data.img + : []; + + const usedFileNames = new Set(); + + for (let idx = 0; idx < imagePaths.length; idx += 1) { + const relPath = imagePaths[idx]; + const encoded = encodePathKeepingSlashes(relPath); + const imageUrl = `${baseImageUrl.replace(/\/+$/, "")}/${encoded.replace(/^\/+/, "")}`; + + const originalBase = path.basename(String(relPath || ""), path.extname(String(relPath || ""))) || `image_${idx + 1}`; + const originalExt = path.extname(String(relPath || "")) || ".png"; + + let localBase = sanitizeName(originalBase) || `image_${idx + 1}`; + let fileName = `${localBase}${originalExt}`; + let dupCounter = 2; + while (usedFileNames.has(fileName.toLowerCase())) { + fileName = `${localBase}_${dupCounter}${originalExt}`; + dupCounter += 1; + } + usedFileNames.add(fileName.toLowerCase()); + + tasks.push({ productDir, fileName, imageUrl, relPath, productName: productNameRaw, productId }); + } + } + + let downloaded = 0; + let skipped = 0; + let failed = 0; + + await mapWithConcurrency(tasks, concurrency, async (task, taskIndex) => { + const filePath = path.join(task.productDir, task.fileName); + + try { + await fs.mkdir(task.productDir, { recursive: true }); + + if (skipExisting) { + try { + await fs.access(filePath); + skipped += 1; + return; + } catch { + // file does not exist, continue + } + } + + const { buffer, contentType } = await downloadWithRetry(task.imageUrl); + + const currentExt = path.extname(task.fileName); + const expectedExt = getExtFromPathOrType(task.relPath, contentType); + let finalFilePath = filePath; + + if (!currentExt && expectedExt) { + finalFilePath = `${filePath}${expectedExt}`; + } + + await fs.writeFile(finalFilePath, buffer); + downloaded += 1; + + if ((taskIndex + 1) % 50 === 0 || taskIndex === tasks.length - 1) { + console.log(`[IMAGES] ${taskIndex + 1}/${tasks.length} processed`); + } + } catch (error) { + failed += 1; + console.log(`[IMAGE-FAIL] ${task.productName} | ${task.imageUrl} -> ${error.message}`); + } + }); + + const summary = { + jsonPath: absJsonPath, + outputDir: absOutputDir, + productsCount: products.length, + totalImagesFound: tasks.length, + downloaded, + skipped, + failed + }; + + return summary; +} + +module.exports = { + downloadProductImagesFromAggregatedJson +}; + +if (require.main === module) { + downloadProductImagesFromAggregatedJson() + .then((summary) => { + console.log("\nDownload summary:"); + console.log(JSON.stringify(summary, null, 2)); + }) + .catch((error) => { + console.error("Image download failed:", error.message); + process.exitCode = 1; + }); +} diff --git a/src/business-logic/kyt-pipeline/03_watermark_downloaded_images.js b/src/business-logic/kyt-pipeline/03_watermark_downloaded_images.js new file mode 100644 index 0000000..2a47ff4 --- /dev/null +++ b/src/business-logic/kyt-pipeline/03_watermark_downloaded_images.js @@ -0,0 +1,250 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); +const sharp = require("sharp"); + +const DEFAULT_IMAGES_DIR = path.join("data", "02_downloaded_product_images"); +const DEFAULT_WATERMARK_PATH = path.join("data", "watermark.png"); +const DEFAULT_STATE_PATH = path.join("data", "03_watermark_state.json"); +const WATERMARK_ENGINE_VERSION = 1; + +const IMAGE_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".tif", + ".tiff", + ".avif" +]); + +async function getAllImageFilesRecursively(rootDir) { + const files = []; + + async function walk(currentDir) { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const ext = path.extname(entry.name).toLowerCase(); + if (IMAGE_EXTENSIONS.has(ext)) { + files.push(fullPath); + } + } + } + + await walk(rootDir); + return files; +} + +async function readStateFile(statePath) { + try { + const raw = await fs.readFile(statePath, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return {}; + } + return parsed; + } catch { + return {}; + } +} + +async function getFileFingerprint(filePath) { + const stat = await fs.stat(filePath); + return `${stat.size}:${Math.trunc(stat.mtimeMs)}`; +} + +async function createTiledWatermark(imageBuffer, watermarkPath) { + const image = sharp(imageBuffer); + const imageMeta = await image.metadata(); + const width = imageMeta.width || 0; + const height = imageMeta.height || 0; + + if (width === 0 || height === 0) { + throw new Error("Image has invalid dimensions"); + } + + const scaledWatermark = await sharp(watermarkPath) + .resize({ width: Math.floor(Math.min(width, height) / 3) }) + .toBuffer(); + + const wmMeta = await sharp(scaledWatermark).metadata(); + const wmWidth = wmMeta.width || 1; + const wmHeight = wmMeta.height || 1; + + const positions = []; + for (let y = 0; y < height; y += wmHeight) { + for (let x = 0; x < width; x += wmWidth) { + positions.push({ input: scaledWatermark, left: x, top: y }); + } + } + + return sharp(imageBuffer).composite(positions).toBuffer(); +} + +async function watermarkImageInPlace(imagePath, watermarkPath) { + const ext = path.extname(imagePath).toLowerCase(); + let image = sharp(imagePath, { animated: false }); + + let meta = await image.metadata(); + let width = meta.width || 0; + let height = meta.height || 0; + const format = (meta.format || "").toLowerCase(); + + if (width === 0 || height === 0) { + throw new Error("Image has invalid metadata"); + } + + // Replicates the old logic: minimum size 500x500 before watermarking. + if (width < 500 || height < 500) { + const newWidth = width < 500 ? 500 : width; + const newHeight = height < 500 ? 500 : height; + image = image.resize(newWidth, newHeight); + width = newWidth; + height = newHeight; + } + + let baseBuffer; + + if (format === "png" || ext === ".png") { + const background = sharp({ + create: { + width, + height, + channels: 4, + background: { r: 255, g: 255, b: 255, alpha: 1 } + } + }); + + baseBuffer = await background + .composite([{ input: await image.toBuffer(), gravity: "center" }]) + .png() + .toBuffer(); + } else { + baseBuffer = await image.jpeg().toBuffer(); + } + + const watermarkedBuffer = await createTiledWatermark(baseBuffer, watermarkPath); + + const writer = sharp(watermarkedBuffer); + if (ext === ".png") { + await writer.png().toFile(imagePath); + } else if (ext === ".webp") { + await writer.webp().toFile(imagePath); + } else if (ext === ".avif") { + await writer.avif().toFile(imagePath); + } else if (ext === ".tif" || ext === ".tiff") { + await writer.tiff().toFile(imagePath); + } else { + await writer.jpeg().toFile(imagePath); + } +} + +async function applyWatermarkToDownloadedImages(options = {}) { + const { + imagesDir = DEFAULT_IMAGES_DIR, + watermarkPath = DEFAULT_WATERMARK_PATH, + statePath = DEFAULT_STATE_PATH + } = options; + + const absImagesDir = path.resolve(process.cwd(), imagesDir); + const absWatermarkPath = path.resolve(process.cwd(), watermarkPath); + const absStatePath = path.resolve(process.cwd(), statePath); + + await fs.access(absImagesDir); + await fs.access(absWatermarkPath); + + await fs.mkdir(path.dirname(absStatePath), { recursive: true }); + + const watermarkFingerprint = await getFileFingerprint(absWatermarkPath); + const state = await readStateFile(absStatePath); + const stateVersion = Number(state.engineVersion || 0); + const stateWatermarkFingerprint = String(state.watermarkFingerprint || ""); + const previousFiles = state.files && typeof state.files === "object" ? state.files : {}; + const stateFiles = + stateVersion === WATERMARK_ENGINE_VERSION && stateWatermarkFingerprint === watermarkFingerprint + ? { ...previousFiles } + : {}; + + const imageFiles = await getAllImageFilesRecursively(absImagesDir); + + let processed = 0; + let skipped = 0; + let failed = 0; + + for (let i = 0; i < imageFiles.length; i += 1) { + const imagePath = imageFiles[i]; + const relativePath = path.relative(absImagesDir, imagePath); + + try { + const beforeFingerprint = await getFileFingerprint(imagePath); + if (stateFiles[relativePath] === beforeFingerprint) { + skipped += 1; + } else { + await watermarkImageInPlace(imagePath, absWatermarkPath); + const afterFingerprint = await getFileFingerprint(imagePath); + stateFiles[relativePath] = afterFingerprint; + processed += 1; + } + } catch (error) { + failed += 1; + console.log(`[WATERMARK-FAIL] ${imagePath} -> ${error.message}`); + } + + if ((i + 1) % 50 === 0 || i === imageFiles.length - 1) { + console.log(`[WATERMARK] ${i + 1}/${imageFiles.length} processed`); + } + } + + // Keep state only for files that still exist. + const currentSet = new Set(imageFiles.map((x) => path.relative(absImagesDir, x))); + for (const rel of Object.keys(stateFiles)) { + if (!currentSet.has(rel)) { + delete stateFiles[rel]; + } + } + + const finalState = { + engineVersion: WATERMARK_ENGINE_VERSION, + watermarkFingerprint, + updatedAt: new Date().toISOString(), + files: stateFiles + }; + await fs.writeFile(absStatePath, JSON.stringify(finalState, null, 2), "utf8"); + + return { + imagesDir: absImagesDir, + watermarkPath: absWatermarkPath, + statePath: absStatePath, + totalImagesFound: imageFiles.length, + processed, + skipped, + failed + }; +} + +module.exports = { + applyWatermarkToDownloadedImages +}; + +if (require.main === module) { + applyWatermarkToDownloadedImages() + .then((summary) => { + console.log("\nWatermark summary:"); + console.log(JSON.stringify(summary, null, 2)); + }) + .catch((error) => { + console.error("Watermark run failed:", error.message); + process.exitCode = 1; + }); +} diff --git a/src/business-logic/kyt-pipeline/04_shopify_image_file_uploader.js b/src/business-logic/kyt-pipeline/04_shopify_image_file_uploader.js new file mode 100644 index 0000000..36aadb8 --- /dev/null +++ b/src/business-logic/kyt-pipeline/04_shopify_image_file_uploader.js @@ -0,0 +1,436 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); + +const DEFAULT_AGGREGATED_JSON = path.join("data", "01_products_aggregated.json"); +const DEFAULT_IMAGES_DIR = path.join("data", "02_downloaded_product_images"); +const DEFAULT_STATE_PATH = path.join("data", "04_shopify_image_upload_state.json"); +const DEFAULT_MAP_PATH = path.join("data", "04_shopify_uploaded_images_map.json"); + +function sanitizeName(value) { + return String(value || "") + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, " ") + .trim() + .slice(0, 150); +} + +function getMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".png") return "image/png"; + if (ext === ".webp") return "image/webp"; + if (ext === ".gif") return "image/gif"; + if (ext === ".avif") return "image/avif"; + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".tif" || ext === ".tiff") return "image/tiff"; + return "application/octet-stream"; +} + +async function fileFingerprint(filePath) { + const stat = await fs.stat(filePath); + return `${stat.size}:${Math.trunc(stat.mtimeMs)}`; +} + +function createShopifyClient(shop, accessToken, apiVersion = "2025-10") { + return { + baseURL: `https://${shop}/admin/api/${apiVersion}/graphql.json`, + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json" + }, + timeout: 30000 + }; +} + +async function postGraphQL(client, query, variables = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), client.timeout || 30000); + try { + const response = await fetch(client.baseURL, { + method: "POST", + headers: client.headers, + body: JSON.stringify({ query, variables }), + signal: controller.signal + }); + + const json = await response.json(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${JSON.stringify(json)}`); + } + if (json.errors?.length) { + throw new Error(`GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`); + } + return json.data; + } finally { + clearTimeout(timer); + } +} + +async function stagedUploadOneImage(client, localPath) { + const stat = await fs.stat(localPath); + const filename = path.basename(localPath); + const mimeType = getMimeType(localPath); + + const staged = await postGraphQL( + client, + `mutation($input: [StagedUploadInput!]!) { + stagedUploadsCreate(input: $input) { + stagedTargets { + url + resourceUrl + parameters { name value } + } + userErrors { field message } + } + }`, + { + input: [ + { + filename, + mimeType, + resource: "FILE", + fileSize: String(stat.size), + httpMethod: "POST" + } + ] + } + ); + + const stagedErrors = staged.stagedUploadsCreate.userErrors || []; + if (stagedErrors.length) { + throw new Error(`stagedUploadsCreate failed: ${stagedErrors.map((e) => e.message).join(", ")}`); + } + + const target = staged.stagedUploadsCreate.stagedTargets?.[0]; + if (!target?.url || !target?.resourceUrl) { + throw new Error("stagedUploadsCreate returned no target."); + } + + const bytes = await fs.readFile(localPath); + const form = new FormData(); + for (const p of target.parameters || []) { + form.append(p.name, p.value); + } + form.append("file", new Blob([bytes], { type: mimeType }), filename); + + const uploadRes = await fetch(target.url, { method: "POST", body: form }); + if (!uploadRes.ok) { + const txt = await uploadRes.text(); + throw new Error(`staged binary upload failed: HTTP ${uploadRes.status} ${txt.slice(0, 240)}`); + } + + const created = await postGraphQL( + client, + `mutation($files: [FileCreateInput!]!) { + fileCreate(files: $files) { + files { + id + alt + fileStatus + ... on MediaImage { + image { url } + } + ... on GenericFile { + url + } + } + userErrors { field message } + } + }`, + { + files: [ + { + alt: filename, + contentType: "IMAGE", + originalSource: target.resourceUrl + } + ] + } + ); + + const createErrors = created.fileCreate.userErrors || []; + if (createErrors.length) { + throw new Error(`fileCreate failed: ${createErrors.map((e) => e.message).join(", ")}`); + } + + const fileNode = created.fileCreate.files?.[0]; + if (!fileNode?.id) { + throw new Error("fileCreate returned no file id."); + } + + return { id: fileNode.id, fileStatus: fileNode.fileStatus || "UPLOADED", url: fileNode.image?.url || fileNode.url || "" }; +} + +async function waitForFileReady(client, fileId, maxAttempts = 20, delayMs = 1500) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const data = await postGraphQL( + client, + `query($id: ID!) { + node(id: $id) { + id + ... on File { + fileStatus + ... on MediaImage { + image { url } + } + ... on GenericFile { + url + } + } + } + }`, + { id: fileId } + ); + + const node = data.node; + const status = node?.fileStatus || "UNKNOWN"; + const url = node?.image?.url || node?.url || ""; + + if (status === "READY") { + return { status, url }; + } + + if (status === "FAILED") { + throw new Error(`File processing failed for ${fileId}`); + } + + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + throw new Error(`Timed out waiting for READY status for ${fileId}`); +} + +function buildLocalImageTasks(aggregatedPayload, imagesDir) { + const products = Array.isArray(aggregatedPayload?.products) ? aggregatedPayload.products : []; + const absImagesDir = path.resolve(process.cwd(), imagesDir); + + const usedFolderNames = new Set(); + const tasks = []; + + for (let i = 0; i < products.length; i += 1) { + const product = products[i] || {}; + const productId = product.productId || product?.productDetails?.data?.id || product?.productSummary?.id || `unknown-${i + 1}`; + const productNameRaw = + product?.productSummary?.name || + product?.productDetails?.data?.name || + `product-${i + 1}`; + + let folderName = sanitizeName(productNameRaw) || `product-${i + 1}`; + if (usedFolderNames.has(folderName)) { + folderName = `${folderName}__${String(productId).slice(0, 8)}`; + } + usedFolderNames.add(folderName); + + const productDir = path.join(absImagesDir, folderName); + const imagePaths = Array.isArray(product?.productSummary?.img) + ? product.productSummary.img + : Array.isArray(product?.productDetails?.data?.img) + ? product.productDetails.data.img + : []; + + const usedFileNames = new Set(); + + for (let idx = 0; idx < imagePaths.length; idx += 1) { + const sourcePath = imagePaths[idx]; + const originalBase = path.basename(String(sourcePath || ""), path.extname(String(sourcePath || ""))) || `image_${idx + 1}`; + const originalExt = path.extname(String(sourcePath || "")) || ".png"; + + const localBase = sanitizeName(originalBase) || `image_${idx + 1}`; + let fileName = `${localBase}${originalExt}`; + let dupCounter = 2; + + while (usedFileNames.has(fileName.toLowerCase())) { + fileName = `${localBase}_${dupCounter}${originalExt}`; + dupCounter += 1; + } + usedFileNames.add(fileName.toLowerCase()); + + tasks.push({ + productId: String(productId), + productName: String(productNameRaw), + sourcePath: String(sourcePath), + localPath: path.join(productDir, fileName) + }); + } + } + + return tasks; +} + +async function readJsonOrDefault(filePath, fallback) { + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : fallback; + } catch { + return fallback; + } +} + +async function uploadKytWatermarkedImagesToShopifyFiles(options = {}) { + const { + shop = process.env.SHOPIFY_SHOP, + accessToken = process.env.SHOPIFY_ACCESS_TOKEN, + apiVersion = process.env.SHOPIFY_API_VERSION || "2025-10", + aggregatedJsonPath = DEFAULT_AGGREGATED_JSON, + imagesDir = DEFAULT_IMAGES_DIR, + statePath = DEFAULT_STATE_PATH, + mapPath = DEFAULT_MAP_PATH + } = options; + + if (!shop) throw new Error("Missing shop (or SHOPIFY_SHOP)."); + if (!accessToken) throw new Error("Missing accessToken (or SHOPIFY_ACCESS_TOKEN)."); + + const client = createShopifyClient(shop, accessToken, apiVersion); + const absAggregatedPath = path.resolve(process.cwd(), aggregatedJsonPath); + const absStatePath = path.resolve(process.cwd(), statePath); + const absMapPath = path.resolve(process.cwd(), mapPath); + + const aggregated = await readJsonOrDefault(absAggregatedPath, { products: [] }); + const tasks = buildLocalImageTasks(aggregated, imagesDir); + + const state = await readJsonOrDefault(absStatePath, { + version: 1, + updatedAt: null, + files: {} + }); + + const stateFiles = state.files && typeof state.files === "object" ? state.files : {}; + const byProduct = {}; + const bySourcePath = {}; + + let processed = 0; + let uploaded = 0; + let skipped = 0; + let failed = 0; + + for (let i = 0; i < tasks.length; i += 1) { + const task = tasks[i]; + processed += 1; + + try { + await fs.access(task.localPath); + } catch { + failed += 1; + continue; + } + + try { + const fp = await fileFingerprint(task.localPath); + const prev = stateFiles[task.localPath]; + + if (prev && prev.fingerprint === fp && prev.status === "READY" && prev.url) { + skipped += 1; + console.log(`[IMG-UPLOAD-SKIP] ${task.productId} | ${task.sourcePath} | ${task.localPath}`); + if (!byProduct[task.productId]) byProduct[task.productId] = []; + byProduct[task.productId].push({ + sourcePath: task.sourcePath, + localPath: task.localPath, + fileId: prev.fileId, + status: prev.status, + url: prev.url + }); + bySourcePath[task.sourcePath] = { + productId: task.productId, + localPath: task.localPath, + fileId: prev.fileId, + status: prev.status, + url: prev.url + }; + } else { + const created = await stagedUploadOneImage(client, task.localPath); + const ready = created.fileStatus === "READY" + ? { status: "READY", url: created.url || "" } + : await waitForFileReady(client, created.id); + + stateFiles[task.localPath] = { + fingerprint: fp, + uploadedAt: new Date().toISOString(), + fileId: created.id, + status: ready.status, + url: ready.url + }; + + uploaded += 1; + console.log(`[IMG-UPLOAD-OK] ${task.productId} | ${task.sourcePath} | fileId=${created.id} | status=${ready.status}`); + if (!byProduct[task.productId]) byProduct[task.productId] = []; + byProduct[task.productId].push({ + sourcePath: task.sourcePath, + localPath: task.localPath, + fileId: created.id, + status: ready.status, + url: ready.url + }); + bySourcePath[task.sourcePath] = { + productId: task.productId, + localPath: task.localPath, + fileId: created.id, + status: ready.status, + url: ready.url + }; + } + } catch (error) { + failed += 1; + stateFiles[task.localPath] = { + fingerprint: null, + uploadedAt: new Date().toISOString(), + status: "FAILED", + error: error.message + }; + console.log(`[IMG-UPLOAD-FAIL] ${task.productName} | ${task.localPath} -> ${error.message}`); + } + + if ((i + 1) % 25 === 0 || i === tasks.length - 1) { + console.log(`[IMG-UPLOAD] ${i + 1}/${tasks.length} processed | uploaded=${uploaded} skipped=${skipped} failed=${failed}`); + } + } + + const finalState = { + version: 1, + updatedAt: new Date().toISOString(), + files: stateFiles + }; + + const finalMap = { + generatedAt: new Date().toISOString(), + sourceAggregatedPath: absAggregatedPath, + totalTasks: tasks.length, + byProduct, + bySourcePath + }; + + await fs.mkdir(path.dirname(absStatePath), { recursive: true }); + await fs.mkdir(path.dirname(absMapPath), { recursive: true }); + await fs.writeFile(absStatePath, JSON.stringify(finalState, null, 2), "utf8"); + await fs.writeFile(absMapPath, JSON.stringify(finalMap, null, 2), "utf8"); + + return { + aggregatedJsonPath: absAggregatedPath, + statePath: absStatePath, + mapPath: absMapPath, + totalTasks: tasks.length, + processed, + uploaded, + skipped, + failed + }; +} + +async function runStandaloneImageUpload() { + const summary = await uploadKytWatermarkedImagesToShopifyFiles(); + console.log("\nImage upload summary:"); + console.log(JSON.stringify(summary, null, 2)); +} + +if (require.main === module) { + runStandaloneImageUpload().catch((error) => { + console.error("Image upload pipeline failed:", error.message); + process.exitCode = 1; + }); +} + +module.exports = { + uploadKytWatermarkedImagesToShopifyFiles, + runStandaloneImageUpload +}; diff --git a/src/business-logic/kyt-pipeline/05_kyt_to_shopify_converter.js b/src/business-logic/kyt-pipeline/05_kyt_to_shopify_converter.js new file mode 100644 index 0000000..635d033 --- /dev/null +++ b/src/business-logic/kyt-pipeline/05_kyt_to_shopify_converter.js @@ -0,0 +1,213 @@ +const fs = require("fs"); +const path = require("path"); + +function escapeHtml(input) { + return String(input || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); +} + +function slugify(str) { + return String(str || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function buildDescriptionHtml(details) { + const blocks = []; + + if (details?.description) { + const paragraph = escapeHtml(details.description).replace(/\n+/g, "
"); + blocks.push(`

${paragraph}

`); + } + + if (Array.isArray(details?.specifications) && details.specifications.length > 0) { + const specItems = details.specifications + .filter((s) => s && s.name && s.text) + .map((s) => `
  • ${escapeHtml(s.name)}: ${escapeHtml(s.text)}
  • `); + + if (specItems.length) { + blocks.push("

    Specifications

    "); + blocks.push(`
      ${specItems.join("")}
    `); + } + } + + if (details?.weight?.text) { + blocks.push(`

    Weight: ${escapeHtml(details.weight.text)}

    `); + } + + return blocks.join("\n") || ""; +} + +function toAbsoluteImageUrl(imgPath, imageBaseUrl) { + if (!imgPath) return null; + if (/^https?:\/\//i.test(imgPath)) return imgPath; + if (!imageBaseUrl) return null; + return `${String(imageBaseUrl).replace(/\/+$/, "")}/${String(imgPath).replace(/^\/+/, "")}`; +} + +function getUploadedImageUrl(imgPath, uploadedImageMap) { + if (!uploadedImageMap || typeof uploadedImageMap !== "object") return null; + const bySourcePath = uploadedImageMap.bySourcePath && typeof uploadedImageMap.bySourcePath === "object" + ? uploadedImageMap.bySourcePath + : {}; + const entry = bySourcePath[String(imgPath)]; + return entry?.url || null; +} + +function convertKytRecordToShopifyReady(record, options = {}) { + const brand = options.brand || "KYT"; + const defaultInventoryName = options.inventoryName || "main"; + + const summary = record?.productSummary || {}; + const data = record?.productDetails?.data || {}; + const details = data?.details || {}; + + const productId = record?.productId || data?.id || summary?.id || cryptoRandomFallback(); + const productName = data?.name || summary?.name || "Unnamed Product"; + const categoryName = data?.category_name || "Helmet"; + const subCategoryName = data?.sub_category_name || record?.subCategory || ""; + const modelSeriesName = data?.model_series_name || record?.modelSeries || ""; + + const mrp = Number(data?.cost?.mrp ?? summary?.cost?.mrp ?? 0); + const totalUnits = Number(data?.stocks?.total_units ?? 0); + const weightValue = Number(details?.weight?.value ?? 0); + const descriptionHtml = buildDescriptionHtml(details); + + const imagePaths = Array.isArray(data?.img) + ? data.img + : Array.isArray(summary?.img) + ? summary.img + : []; + + const imageFiles = imagePaths + .map((p) => ({ + type: "Image", + url: getUploadedImageUrl(p, options.uploadedImageMap) || toAbsoluteImageUrl(p, options.imageBaseUrl), + media_content: path.basename(String(p || "")), + source_path: p, + })) + .filter((f) => Boolean(f.url)); + + const subcategoryCombined = [subCategoryName, modelSeriesName].filter(Boolean).join(", "); + + const tags = [ + brand, + categoryName, + subCategoryName, + modelSeriesName, + "Helmet", + details?.sizechart?.name || null, + ].filter(Boolean); + + return { + id: productId, + source: { + productId, + subCategory: record?.subCategory || null, + modelSeries: record?.modelSeries || null, + image_paths: imagePaths, + }, + attributes: { + product_name: productName, + brand, + category: categoryName, + subcategory: subcategoryCombined, + part_number: data?.code || productId, + mfr_part_number: data?.code || productId, + price: mrp, + compare_price: null, + purchase_cost: null, + barcode: "", + price_group: null, + units_per_sku: null, + part_description: descriptionHtml, + descriptions: descriptionHtml + ? [{ type: "Market Description", description: descriptionHtml }] + : [], + files: imageFiles, + image_paths: imagePaths, + dimensions: [{ weight: weightValue }], + total_quantity: totalUnits, + inventorydata: { + inventory: { + [defaultInventoryName]: totalUnits, + }, + }, + fitmentTags: { + make: [], + model: [], + year: [], + drive: [], + baseModel: [], + }, + tags, + handle: slugify(`${brand}-${productName}-${productId}`), + }, + }; +} + +function convertKytJsonToShopifyProducts(input, options = {}) { + const records = Array.isArray(input?.products) ? input.products : []; + return records.map((record) => convertKytRecordToShopifyReady(record, options)); +} + +function cryptoRandomFallback() { + return `kyt-${Math.random().toString(36).slice(2, 10)}`; +} + +function parseCliArgs(argv) { + const out = { + input: "data/01_products_aggregated.json", + output: "data/05_shopify_products_ready.json", + imageBaseUrl: "", + brand: "KYT", + }; + + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--input") out.input = argv[i + 1]; + if (a === "--output") out.output = argv[i + 1]; + if (a === "--image-base-url") out.imageBaseUrl = argv[i + 1]; + if (a === "--brand") out.brand = argv[i + 1]; + } + + return out; +} + +function runCli() { + const args = parseCliArgs(process.argv.slice(2)); + const inputPath = path.resolve(args.input); + const outputPath = path.resolve(args.output); + + const raw = fs.readFileSync(inputPath, "utf8"); + const json = JSON.parse(raw); + const converted = convertKytJsonToShopifyProducts(json, { + imageBaseUrl: args.imageBaseUrl || "", + brand: args.brand || "KYT", + }); + + const payload = { + generatedAt: new Date().toISOString(), + sourceFile: inputPath, + totalProducts: converted.length, + products: converted, + }; + + fs.writeFileSync(outputPath, JSON.stringify(payload, null, 2)); + console.log(`Converted ${converted.length} products -> ${outputPath}`); +} + +if (require.main === module) { + runCli(); +} + +module.exports = { + convertKytRecordToShopifyReady, + convertKytJsonToShopifyProducts, +}; diff --git a/src/business-logic/kyt-pipeline/06_shopify_product_upsert.js b/src/business-logic/kyt-pipeline/06_shopify_product_upsert.js new file mode 100644 index 0000000..3509297 --- /dev/null +++ b/src/business-logic/kyt-pipeline/06_shopify_product_upsert.js @@ -0,0 +1,595 @@ +const crypto = require("crypto"); + +function slugify(str) { + return String(str || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function extractFirstJsonObject(text) { + if (typeof text !== "string") return null; + 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; +} + +function createShopifyClient(shop, accessToken, apiVersion = "2025-10") { + return { + baseURL: `https://${shop}/admin/api/${apiVersion}/graphql.json`, + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + timeout: 30000, + }; +} + +function createSeoLlmClient(baseURL = "https://llm.thedomainnest.com") { + return { + baseURL, + headers: { "Content-Type": "application/json" }, + timeout: 30000, + }; +} + +async function postJson(client, endpoint, body) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), client.timeout || 30000); + try { + const response = await fetch(`${client.baseURL}${endpoint}`, { + method: "POST", + headers: client.headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + const json = await response.json(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${JSON.stringify(json)}`); + } + return json; + } finally { + clearTimeout(timer); + } +} + +async function gql(client, query, variables = {}) { + const data = await postJson(client, "", { query, variables }); + if (data.errors?.length) { + throw new Error(`GraphQL errors: ${data.errors.map((e) => e.message).join(", ")}`); + } + return data.data; +} + +function normalizeFitmentTags(input) { + const map = { + make: new Set(), + model: new Set(), + year: new Set(), + drive: new Set(), + baseModel: new Set(), + }; + + const src = input || {}; + for (const key of Object.keys(map)) { + const arr = Array.isArray(src[key]) ? src[key] : []; + arr.forEach((v) => map[key].add(String(v))); + } + + const fitmentMap = {}; + Object.keys(map).forEach((k) => { + fitmentMap[k] = Array.from(map[k]); + }); + + const fitmentFlat = Array.from(new Set(Object.values(fitmentMap).flat())); + return { fitmentMap, fitmentFlat }; +} + +async function generateSeo(attrs, seoClient) { + const seoInput = { + product_name: attrs.product_name, + descriptions: attrs.descriptions, + brand: attrs.brand, + category: attrs.category, + subcategory: attrs.subcategory, + part_number: attrs.part_number, + price: attrs.price, + }; + + const requestBody = { + message: `Use the following product JSON as the only source of truth. Create:\n1) seo_title (max 70 characters min 60 characters)\n2) seo_description (max 160 characters min 140 characters)\n\nProduct JSON:\n${JSON.stringify(seoInput)}`, + mode: "quality", + system_prompt: `You are an SEO metadata generator for automotive performance parts. Output ONLY valid JSON with schema: {"seo_title":"","seo_description":""}.`, + session_id: crypto.randomUUID(), + image_base64: null, + file_name: null, + file_base64: null, + }; + + try { + const res = await postJson(seoClient, "/chat-json", requestBody); + const extracted = extractFirstJsonObject(res?.reply || ""); + if (!extracted) return { seo_title: "", seo_description: "" }; + const parsed = JSON.parse(extracted); + return { + seo_title: parsed.seo_title || "", + seo_description: parsed.seo_description || "", + }; + } catch { + return { seo_title: "", seo_description: "" }; + } +} + +async function getOrCreateManualCollections(client, titles) { + const ids = []; + + for (const title of titles) { + const clean = String(title || "").trim(); + if (!clean) continue; + + const lookup = await gql( + client, + `query ($q: String!) { collections(first: 1, query: $q) { nodes { id } } }`, + { q: `title:"${clean}" AND collection_type:manual` } + ); + + const existing = lookup.collections.nodes?.[0]; + if (existing) { + ids.push(existing.id); + continue; + } + + const created = await gql( + client, + `mutation($input: CollectionInput!) { + collectionCreate(input: $input) { + collection { id } + userErrors { field message } + } + }`, + { input: { title: clean } } + ); + + const errs = created.collectionCreate.userErrors || []; + if (errs.length) { + throw new Error(`collectionCreate failed for "${clean}": ${errs.map((e) => e.message).join(", ")}`); + } + + ids.push(created.collectionCreate.collection.id); + } + + return ids; +} + +async function getPricingConfig(client) { + const data = await gql( + client, + `query { + shop { metafield(namespace: "turn14", key: "pricing_config") { value } } + }` + ); + + let priceType = "map"; + let percentage = 0; + + const value = data.shop?.metafield?.value; + if (value) { + try { + const parsed = JSON.parse(value); + priceType = parsed.priceType || "map"; + percentage = Number(parsed.percentage) || 0; + } catch { + priceType = "map"; + percentage = 0; + } + } + + return { priceType, percentage }; +} + +async function publishToOnlineStore(client, productId) { + const pubs = await gql( + client, + `query { + publications(first: 20) { edges { node { id name } } } + }` + ); + + const publication = pubs.publications.edges.find((e) => e.node.name === "Online Store"); + if (!publication) throw new Error("Online Store publication not found."); + + const res = await gql( + client, + `mutation($id: ID!, $publicationId: ID!) { + publishablePublish(id: $id, input: { publicationId: $publicationId }) { + userErrors { field message } + } + }`, + { id: productId, publicationId: publication.node.id } + ); + + const errs = res.publishablePublish.userErrors || []; + if (errs.length) throw new Error(`publishablePublish failed: ${errs.map((e) => e.message).join(", ")}`); +} + +async function upsertShopifyProductFull({ + shop, + accessToken, + product, + locationId = null, + enableSeo = true, + apiVersion = "2025-10", + seoBaseURL = "https://llm.thedomainnest.com", +}) { + if (!shop) throw new Error("shop is required"); + if (!accessToken) throw new Error("accessToken is required"); + if (!product) throw new Error("product is required"); + + const client = createShopifyClient(shop, accessToken, apiVersion); + const seoClient = createSeoLlmClient(seoBaseURL); + + const attrs = product.attributes || product; + const handle = slugify(attrs.handle || product.id || attrs.part_number || attrs.product_name); + + const subcats = String(attrs.subcategory || "") + .split(/[,/]/) + .map((s) => s.trim()) + .filter(Boolean); + + const { fitmentFlat } = normalizeFitmentTags(attrs.fitmentTags || attrs.fitmmentTags); + + const collectionTitles = Array.from( + new Set([attrs.category, ...subcats, attrs.brand, ...fitmentFlat].filter(Boolean)) + ); + + const collectionIds = await getOrCreateManualCollections(client, collectionTitles); + + const productTags = [ + attrs.category, + ...subcats, + ...fitmentFlat, + attrs.brand, + attrs.part_number, + attrs.mfr_part_number, + attrs.price_group, + attrs.units_per_sku ? `${attrs.units_per_sku} per SKU` : null, + attrs.barcode, + ...(Array.isArray(attrs.tags) ? attrs.tags : []), + ] + .filter(Boolean) + .map((t) => String(t).trim()); + + const mediaInputs = (attrs.files || []) + .filter((f) => f.type === "Image" && f.url) + .map((f) => ({ + originalSource: f.url, + mediaContentType: "IMAGE", + alt: `${attrs.product_name || attrs.part_number || "Product"} - ${f.media_content || "image"}`, + })); + + const marketDescs = (attrs.descriptions || []) + .filter((d) => d.type === "Market Description") + .map((d) => d.description) + .filter(Boolean); + + const descriptionHtml = marketDescs.length + ? marketDescs.reduce((a, b) => (b.length > a.length ? b : a)) + : attrs.part_description || ""; + + const existingSearch = await gql( + client, + `query($q: String!) { + products(first: 1, query: $q) { + nodes { + id + handle + variants(first: 1) { nodes { id inventoryItem { id } } } + } + } + }`, + { q: `handle:${handle}` } + ); + + const existing = existingSearch.products.nodes?.[0]; + let productId; + let variantId; + let inventoryItemId; + let action; + + if (existing) { + const upd = await gql( + client, + `mutation($product: ProductUpdateInput!, $media: [CreateMediaInput!]) { + productUpdate(product: $product, media: $media) { + product { id } + userErrors { field message } + } + }`, + { + product: { + id: existing.id, + title: attrs.product_name || attrs.part_number || "Untitled", + descriptionHtml, + vendor: attrs.brand || "", + productType: attrs.category || "", + handle, + tags: productTags, + }, + media: mediaInputs.length ? mediaInputs : null + } + ); + + const errs = upd.productUpdate.userErrors || []; + if (errs.length) throw new Error(`productUpdate failed: ${errs.map((e) => e.message).join(", ")}`); + + productId = existing.id; + variantId = existing.variants?.nodes?.[0]?.id; + inventoryItemId = existing.variants?.nodes?.[0]?.inventoryItem?.id; + action = "updated"; + } else { + const crt = await gql( + client, + `mutation($product: ProductCreateInput!, $media: [CreateMediaInput!]) { + productCreate(product: $product, media: $media) { + product { + id + variants(first: 1) { nodes { id inventoryItem { id } } } + } + userErrors { field message } + } + }`, + { + product: { + title: attrs.product_name || attrs.part_number || "Untitled", + descriptionHtml, + vendor: attrs.brand || "", + productType: attrs.category || "", + handle, + tags: productTags, + collectionsToJoin: collectionIds, + status: "ACTIVE", + }, + media: mediaInputs, + } + ); + + const errs = crt.productCreate.userErrors || []; + if (errs.length) throw new Error(`productCreate failed: ${errs.map((e) => e.message).join(", ")}`); + + productId = crt.productCreate.product.id; + variantId = crt.productCreate.product.variants?.nodes?.[0]?.id; + inventoryItemId = crt.productCreate.product.variants?.nodes?.[0]?.inventoryItem?.id; + action = "created"; + } + + if (!variantId) throw new Error("No variant ID found after upsert."); + + const { priceType, percentage } = await getPricingConfig(client); + const basePrice = Number(attrs.price || 0); + let finalPrice = basePrice; + if (priceType === "percentage") { + finalPrice = basePrice + (basePrice * percentage) / 100; + } + + const compareAtPrice = attrs.compare_price != null ? Number(attrs.compare_price) : null; + const weightValue = Number(attrs.dimensions?.[0]?.weight || 0); + + const bulk = await gql( + client, + `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { + productVariantsBulkUpdate(productId: $productId, variants: $variants) { + productVariants { id price barcode inventoryItem { id sku } } + userErrors { field message } + } + }`, + { + productId, + variants: [ + { + id: variantId, + price: Number(finalPrice).toFixed(2), + ...(compareAtPrice !== null ? { compareAtPrice: Number(compareAtPrice).toFixed(2) } : {}), + ...(attrs.barcode ? { barcode: attrs.barcode } : {}), + inventoryItem: { + sku: attrs.part_number || "", + measurement: { weight: { value: weightValue, unit: "POUNDS" } }, + }, + }, + ], + } + ); + + if (bulk.productVariantsBulkUpdate.userErrors?.length) { + throw new Error(`variant update failed: ${bulk.productVariantsBulkUpdate.userErrors.map((e) => e.message).join(", ")}`); + } + + if (inventoryItemId) { + const inv = await gql( + client, + `mutation($id: ID!, $input: InventoryItemInput!) { + inventoryItemUpdate(id: $id, input: $input) { + inventoryItem { id sku tracked } + userErrors { field message } + } + }`, + { + id: inventoryItemId, + input: { + cost: Number(attrs.purchase_cost || 0), + tracked: true, + }, + } + ); + + if (inv.inventoryItemUpdate.userErrors?.length) { + throw new Error(`inventoryItemUpdate failed: ${inv.inventoryItemUpdate.userErrors.map((e) => e.message).join(", ")}`); + } + + if (locationId) { + try { + const act = await gql( + client, + `mutation($inventoryItemId: ID!, $locationId: ID!) { + inventoryActivate(inventoryItemId: $inventoryItemId, locationId: $locationId) { + inventoryLevel { id } + userErrors { field message } + } + }`, + { inventoryItemId, locationId } + ); + + if (act.inventoryActivate.userErrors?.length) { + throw new Error(`inventoryActivate failed: ${act.inventoryActivate.userErrors.map((e) => e.message).join(", ")}`); + } + + const totalQty = Number(attrs.total_quantity ?? attrs.quantity ?? 0); + const setQty = await gql( + client, + `mutation($input: InventorySetQuantitiesInput!) { + inventorySetQuantities(input: $input) { + userErrors { field message } + } + }`, + { + input: { + name: "available", + reason: "correction", + ignoreCompareQuantity: true, + quantities: [ + { + inventoryItemId, + locationId, + quantity: totalQty, + compareQuantity: 0, + }, + ], + }, + } + ); + + if (setQty.inventorySetQuantities.userErrors?.length) { + throw new Error(`inventorySetQuantities failed: ${setQty.inventorySetQuantities.userErrors.map((e) => e.message).join(", ")}`); + } + } catch (inventoryError) { + console.log( + `[INVENTORY-WARN] Skipping inventory activation/set for product ${productId}. Reason: ${inventoryError.message}` + ); + } + } else { + console.log(`[INVENTORY-WARN] No locationId provided. Skipping inventory activation/set for product ${productId}.`); + } + } + + await publishToOnlineStore(client, productId); + + if (enableSeo) { + const seo = await generateSeo(attrs, seoClient); + const seoUpd = await gql( + client, + `mutation($product: ProductUpdateInput!) { + productUpdate(product: $product) { + product { id seo { title description } } + userErrors { field message } + } + }`, + { + product: { + id: productId, + seo: { + title: seo.seo_title || `${attrs.product_name || "Product"} | Performance Auto Parts`, + description: + seo.seo_description || + `Find high-quality ${attrs.product_name || "automotive parts"} built for reliability and performance.`, + }, + }, + } + ); + + if (seoUpd.productUpdate.userErrors?.length) { + throw new Error(`SEO productUpdate failed: ${seoUpd.productUpdate.userErrors.map((e) => e.message).join(", ")}`); + } + } + + return { + ok: true, + action, + productId, + variantId, + inventoryItemId, + handle, + }; +} + +async function runStandaloneSelfTest() { + const shop = process.env.SHOPIFY_SHOP; + const accessToken = process.env.SHOPIFY_ACCESS_TOKEN; + const locationId = process.env.SHOPIFY_LOCATION_ID || null; + + if (!shop || !accessToken) { + throw new Error("Missing env. Set SHOPIFY_SHOP and SHOPIFY_ACCESS_TOKEN (optional SHOPIFY_LOCATION_ID)"); + } + + const dummyProduct = { + id: "kyt-demo-001", + attributes: { + product_name: "KYT Demo Helmet Race Edition", + brand: "KYT", + category: "Helmet", + subcategory: "Racing, Demo Series", + part_number: "KYT-DEMO-001", + mfr_part_number: "KYT-DEMO-001", + price: 57000, + compare_price: 59000, + purchase_cost: 42000, + barcode: "", + price_group: "premium", + units_per_sku: 1, + part_description: "

    Demo KYT helmet description for Shopify upsert testing.

    ", + descriptions: [ + { type: "Market Description", description: "

    Demo KYT helmet for end-to-end Shopify test.

    " }, + ], + files: [], + dimensions: [{ weight: 1450 }], + total_quantity: 5, + inventorydata: { inventory: { main: 5 } }, + fitmentTags: { make: [], model: [], year: [], drive: [], baseModel: [] }, + tags: ["KYT", "Helmet", "Racing", "Demo"], + handle: slugify(`kyt-demo-001-${Date.now()}`), + }, + }; + + const result = await upsertShopifyProductFull({ + shop, + accessToken, + product: dummyProduct, + locationId, + enableSeo: true, + }); + + console.log("Self-test success:", result); +} + +if (require.main === module) { + runStandaloneSelfTest().catch((err) => { + console.error("Self-test failed:", err.message); + process.exit(1); + }); +} + +module.exports = { + upsertShopifyProductFull, + runStandaloneSelfTest, +}; diff --git a/src/pipelineJobs.js b/src/pipelineJobs.js new file mode 100644 index 0000000..174d1de --- /dev/null +++ b/src/pipelineJobs.js @@ -0,0 +1,64 @@ +const jobs = {}; + +function listJobs() { + return Object.values(jobs) + .sort((a, b) => String(b.startedAt || "").localeCompare(String(a.startedAt || ""))); +} + +function getJob(jobId) { + return jobs[jobId] || null; +} + +function canStartJob(shop) { + if (!shop) { + return false; + } + + const existing = jobs[shop]; + return !existing || existing.status === "done" || existing.status === "error"; +} + +function createJob(payload = {}) { + const id = String(payload.shop || "").trim(); + if (!id) { + throw new Error("Shop is required to create a job."); + } + + jobs[id] = { + id, + status: "queued", + step: "queued", + stepIndex: 0, + totalSteps: 6, + detail: null, + summary: null, + error: null, + payload, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return jobs[id]; +} + +function updateJob(jobId, patch) { + const current = jobs[jobId]; + if (!current) { + return null; + } + + jobs[jobId] = { + ...current, + ...patch, + updatedAt: new Date().toISOString(), + }; + + return jobs[jobId]; +} + +module.exports = { + listJobs, + getJob, + canStartJob, + createJob, + updateJob, +}; diff --git a/src/runKytPipelineJob.js b/src/runKytPipelineJob.js new file mode 100644 index 0000000..b8dcdad --- /dev/null +++ b/src/runKytPipelineJob.js @@ -0,0 +1,84 @@ +const path = require("node:path"); +const { getToken } = require("../tokenStore"); +const { log } = require("../logger"); +const { runFullKytPipeline } = require("./business-logic/kyt-pipeline/00_index"); +const { updateJob } = require("./pipelineJobs"); + +async function runKytPipelineJob(job) { + const { shop, limit } = job.payload || {}; + const tokenRecord = getToken(shop); + + if (!tokenRecord) { + updateJob(job.id, { + status: "error", + step: "auth", + error: `No stored Shopify token for shop ${shop}`, + }); + return; + } + + const previousEnv = { + SHOPIFY_SHOP: process.env.SHOPIFY_SHOP, + SHOPIFY_ACCESS_TOKEN: process.env.SHOPIFY_ACCESS_TOKEN, + SHOPIFY_LOCATION_ID: process.env.SHOPIFY_LOCATION_ID, + WATERMARK_PATH: process.env.WATERMARK_PATH, + }; + + process.env.SHOPIFY_SHOP = shop; + process.env.SHOPIFY_ACCESS_TOKEN = tokenRecord.accessToken; + process.env.SHOPIFY_LOCATION_ID = tokenRecord.locationId || ""; + process.env.WATERMARK_PATH = process.env.WATERMARK_PATH || "data/watermark.png"; + + const originalArgv = process.argv.slice(); + process.argv = [originalArgv[0], originalArgv[1]]; + if (limit) { + process.argv.push("--limit", String(limit)); + } + + try { + updateJob(job.id, { + status: "running", + step: "starting", + detail: `Starting KYT pipeline for ${shop}`, + }); + + const summary = await runFullKytPipeline({ + onProgress(progress) { + updateJob(job.id, { + status: "running", + step: progress.stepKey, + stepIndex: progress.stepIndex, + totalSteps: progress.totalSteps, + detail: progress.message, + }); + }, + }); + + updateJob(job.id, { + status: "done", + step: "completed", + stepIndex: 6, + totalSteps: 6, + detail: "Pipeline completed successfully", + summary, + }); + log(shop, `KYT pipeline completed for job ${job.id}`); + } catch (error) { + updateJob(job.id, { + status: "error", + error: error.message, + detail: `Pipeline failed: ${error.message}`, + }); + log(shop, `KYT pipeline failed for job ${job.id}: ${error.message}`); + } finally { + process.argv = originalArgv; + process.env.SHOPIFY_SHOP = previousEnv.SHOPIFY_SHOP; + process.env.SHOPIFY_ACCESS_TOKEN = previousEnv.SHOPIFY_ACCESS_TOKEN; + process.env.SHOPIFY_LOCATION_ID = previousEnv.SHOPIFY_LOCATION_ID; + process.env.WATERMARK_PATH = previousEnv.WATERMARK_PATH; + } +} + +module.exports = { + runKytPipelineJob, +}; diff --git a/tokenStore.js b/tokenStore.js new file mode 100644 index 0000000..684f4de --- /dev/null +++ b/tokenStore.js @@ -0,0 +1,63 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const dataFile = path.resolve(__dirname, "data", "tokens.json"); +const dataDir = path.dirname(dataFile); + +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +if (!fs.existsSync(dataFile)) { + fs.writeFileSync(dataFile, "{}", "utf8"); +} + +function readStore() { + return JSON.parse(fs.readFileSync(dataFile, "utf8")); +} + +function saveStore(store) { + fs.writeFileSync(dataFile, JSON.stringify(store, null, 2), "utf8"); +} + +function saveToken(shop, accessToken, scope, fulfillmentService = null, locationId = null) { + if (!shop || accessToken == null || scope == null) { + return; + } + + const store = readStore(); + store[shop] = { + accessToken, + scope, + savedAt: new Date().toISOString(), + locationId: locationId || store[shop]?.locationId || null, + fulfillmentService: fulfillmentService || store[shop]?.fulfillmentService || null, + }; + saveStore(store); +} + +function getToken(shop) { + const store = readStore(); + return store[shop] || null; +} + +function deleteToken(shop) { + if (!shop) { + return; + } + + const store = readStore(); + delete store[shop]; + saveStore(store); +} + +function listTokens() { + return readStore(); +} + +module.exports = { + saveToken, + getToken, + deleteToken, + listTokens, +};