diff --git a/.gitignore b/.gitignore index dedec3d..2c98b09 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,8 @@ inventory-server/scripts/.fuse_hidden00000fa20000000a # Ignore compiled Vite config to avoid duplication vite.config.js inventory-server/inventory_backup.sql -chat-files.tar.gz \ No newline at end of file +chat-files.tar.gz +chat-migration*/ +**/chat-migration*/ +chat-migration*/** +**/chat-migration*/** diff --git a/inventory-server/auth/add-user.js b/inventory-server/auth/add-user.js new file mode 100644 index 0000000..d6857bc --- /dev/null +++ b/inventory-server/auth/add-user.js @@ -0,0 +1,103 @@ +require('dotenv').config({ path: '../.env' }); +const bcrypt = require('bcrypt'); +const { Pool } = require('pg'); +const inquirer = require('inquirer'); + +// Log connection details for debugging (remove in production) +console.log('Attempting to connect with:', { + host: process.env.DB_HOST, + user: process.env.DB_USER, + database: process.env.DB_NAME, + port: process.env.DB_PORT +}); + +const pool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT, +}); + +async function promptUser() { + const questions = [ + { + type: 'input', + name: 'username', + message: 'Enter username:', + validate: (input) => { + if (input.length < 3) { + return 'Username must be at least 3 characters long'; + } + return true; + } + }, + { + type: 'password', + name: 'password', + message: 'Enter password:', + mask: '*', + validate: (input) => { + if (input.length < 8) { + return 'Password must be at least 8 characters long'; + } + return true; + } + }, + { + type: 'password', + name: 'confirmPassword', + message: 'Confirm password:', + mask: '*', + validate: (input, answers) => { + if (input !== answers.password) { + return 'Passwords do not match'; + } + return true; + } + } + ]; + + return inquirer.prompt(questions); +} + +async function addUser() { + try { + // Get user input + const answers = await promptUser(); + const { username, password } = answers; + + // Hash password + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + // Check if user already exists + const checkResult = await pool.query( + 'SELECT id FROM users WHERE username = $1', + [username] + ); + + if (checkResult.rows.length > 0) { + console.error('Error: Username already exists'); + process.exit(1); + } + + // Insert new user + const result = await pool.query( + 'INSERT INTO users (username, password) VALUES ($1, $2) RETURNING id', + [username, hashedPassword] + ); + + console.log(`User ${username} created successfully with id ${result.rows[0].id}`); + } catch (error) { + console.error('Error creating user:', error); + console.error('Error details:', error.message); + if (error.code) { + console.error('Error code:', error.code); + } + } finally { + await pool.end(); + } +} + +addUser(); \ No newline at end of file diff --git a/inventory-server/auth/package-lock.json b/inventory-server/auth/package-lock.json new file mode 100644 index 0000000..f451469 --- /dev/null +++ b/inventory-server/auth/package-lock.json @@ -0,0 +1,2275 @@ +{ + "name": "inventory-auth-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inventory-auth-server", + "version": "1.0.0", + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "inquirer": "^8.2.6", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "pg": "^8.11.3" + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", + "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "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/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/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/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "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.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "license": "MIT" + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "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-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "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/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "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.0.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.1.tgz", + "integrity": "sha512-BPOBuyUF9QIVhuNLhbToCLHP6+0MHwZ7xLBkPPCZqK4JmpJgGnv10035STzzQwFpqdzNFMB3irvDI63IagvDwA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "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/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "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": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "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/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "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/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/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/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "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/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.13.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", + "integrity": "sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.1", + "pg-protocol": "^1.7.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.1.tgz", + "integrity": "sha512-xIOsFoh7Vdhojas6q3596mXFsR8nwBQBXX5JiV7p9buEVAGqYL4yFzclON5P9vFrpu1u7Zwl2oriyDa89n0wbw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.1.tgz", + "integrity": "sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "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/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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" + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=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/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/inventory-server/auth/package.json b/inventory-server/auth/package.json new file mode 100644 index 0000000..bbbc607 --- /dev/null +++ b/inventory-server/auth/package.json @@ -0,0 +1,19 @@ +{ + "name": "inventory-auth-server", + "version": "1.0.0", + "description": "Authentication server for inventory management system", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "inquirer": "^8.2.6", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "pg": "^8.11.3" + } +} diff --git a/inventory-server/auth/permissions.js b/inventory-server/auth/permissions.js new file mode 100644 index 0000000..cfdd688 --- /dev/null +++ b/inventory-server/auth/permissions.js @@ -0,0 +1,128 @@ +// Get pool from global or create a new one if not available +let pool; +if (typeof global.pool !== 'undefined') { + pool = global.pool; +} else { + // If global pool is not available, create a new connection + const { Pool } = require('pg'); + pool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT, + }); + console.log('Created new database pool in permissions.js'); +} + +/** + * Check if a user has a specific permission + * @param {number} userId - The user ID to check + * @param {string} permissionCode - The permission code to check + * @returns {Promise} - Whether the user has the permission + */ +async function checkPermission(userId, permissionCode) { + try { + // First check if the user is an admin + const adminResult = await pool.query( + 'SELECT is_admin FROM users WHERE id = $1', + [userId] + ); + + // If user is admin, automatically grant permission + if (adminResult.rows.length > 0 && adminResult.rows[0].is_admin) { + return true; + } + + // Otherwise check for specific permission + const result = await pool.query( + `SELECT COUNT(*) AS has_permission + FROM user_permissions up + JOIN permissions p ON up.permission_id = p.id + WHERE up.user_id = $1 AND p.code = $2`, + [userId, permissionCode] + ); + + return result.rows[0].has_permission > 0; + } catch (error) { + console.error('Error checking permission:', error); + return false; + } +} + +/** + * Middleware to require a specific permission + * @param {string} permissionCode - The permission code required + * @returns {Function} - Express middleware function + */ +function requirePermission(permissionCode) { + return async (req, res, next) => { + try { + // Check if user is authenticated + if (!req.user || !req.user.id) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const hasPermission = await checkPermission(req.user.id, permissionCode); + + if (!hasPermission) { + return res.status(403).json({ + error: 'Insufficient permissions', + requiredPermission: permissionCode + }); + } + + next(); + } catch (error) { + console.error('Permission middleware error:', error); + res.status(500).json({ error: 'Server error checking permissions' }); + } + }; +} + +/** + * Get all permissions for a user + * @param {number} userId - The user ID + * @returns {Promise} - Array of permission codes + */ +async function getUserPermissions(userId) { + try { + // Check if user is admin + const adminResult = await pool.query( + 'SELECT is_admin FROM users WHERE id = $1', + [userId] + ); + + if (adminResult.rows.length === 0) { + return []; + } + + const isAdmin = adminResult.rows[0].is_admin; + + if (isAdmin) { + // Admin gets all permissions + const allPermissions = await pool.query('SELECT code FROM permissions'); + return allPermissions.rows.map(p => p.code); + } else { + // Get assigned permissions + const permissions = await pool.query( + `SELECT p.code + FROM permissions p + JOIN user_permissions up ON p.id = up.permission_id + WHERE up.user_id = $1`, + [userId] + ); + + return permissions.rows.map(p => p.code); + } + } catch (error) { + console.error('Error getting user permissions:', error); + return []; + } +} + +module.exports = { + checkPermission, + requirePermission, + getUserPermissions +}; \ No newline at end of file diff --git a/inventory-server/auth/routes.js b/inventory-server/auth/routes.js new file mode 100644 index 0000000..ef6636d --- /dev/null +++ b/inventory-server/auth/routes.js @@ -0,0 +1,533 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { requirePermission, getUserPermissions } = require('./permissions'); + +// Get pool from global or create a new one if not available +let pool; +if (typeof global.pool !== 'undefined') { + pool = global.pool; +} else { + // If global pool is not available, create a new connection + const { Pool } = require('pg'); + pool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT, + }); + console.log('Created new database pool in routes.js'); +} + +// Authentication middleware +const authenticate = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Get user from database + const result = await pool.query( + 'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1', + [decoded.userId] + ); + + console.log('Database query result for user', decoded.userId, ':', result.rows[0]); + + if (result.rows.length === 0) { + return res.status(401).json({ error: 'User not found' }); + } + + // Attach user to request + req.user = result.rows[0]; + next(); + } catch (error) { + console.error('Authentication error:', error); + res.status(401).json({ error: 'Invalid token' }); + } +}; + +// Login route +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + // Get user from database + const result = await pool.query( + 'SELECT id, username, password, is_admin, is_active, rocket_chat_user_id FROM users WHERE username = $1', + [username] + ); + + if (result.rows.length === 0) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + const user = result.rows[0]; + + // Check if user is active + if (!user.is_active) { + return res.status(403).json({ error: 'Account is inactive' }); + } + + // Verify password + const validPassword = await bcrypt.compare(password, user.password); + if (!validPassword) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + // Update last login + await pool.query( + 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1', + [user.id] + ); + + // Generate JWT + const token = jwt.sign( + { userId: user.id, username: user.username }, + process.env.JWT_SECRET, + { expiresIn: '8h' } + ); + + // Get user permissions + const permissions = await getUserPermissions(user.id); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + is_admin: user.is_admin, + rocket_chat_user_id: user.rocket_chat_user_id, + permissions + } + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get current user +router.get('/me', authenticate, async (req, res) => { + try { + // Get user permissions + const permissions = await getUserPermissions(req.user.id); + + res.json({ + id: req.user.id, + username: req.user.username, + email: req.user.email, + is_admin: req.user.is_admin, + rocket_chat_user_id: req.user.rocket_chat_user_id, + permissions, + // Debug info + _debug_raw_user: req.user, + _server_identifier: "INVENTORY_AUTH_SERVER_MODIFIED" + }); + } catch (error) { + console.error('Error getting current user:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get all users +router.get('/users', authenticate, requirePermission('view:users'), async (req, res) => { + try { + const result = await pool.query(` + SELECT id, username, email, is_admin, is_active, rocket_chat_user_id, created_at, last_login + FROM users + ORDER BY username + `); + + res.json(result.rows); + } catch (error) { + console.error('Error getting users:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get user with permissions +router.get('/users/:id', authenticate, requirePermission('view:users'), async (req, res) => { + try { + const userId = req.params.id; + + // Get user details + const userResult = await pool.query(` + SELECT id, username, email, is_admin, is_active, rocket_chat_user_id, created_at, last_login + FROM users + WHERE id = $1 + `, [userId]); + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + // Get user permissions + const permissionsResult = await pool.query(` + SELECT p.id, p.name, p.code, p.category, p.description + FROM permissions p + JOIN user_permissions up ON p.id = up.permission_id + WHERE up.user_id = $1 + ORDER BY p.category, p.name + `, [userId]); + + // Combine user and permissions + const user = { + ...userResult.rows[0], + permissions: permissionsResult.rows + }; + + res.json(user); + } catch (error) { + console.error('Error getting user:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Create new user +router.post('/users', authenticate, requirePermission('create:users'), async (req, res) => { + const client = await pool.connect(); + + try { + const { username, email, password, is_admin, is_active, rocket_chat_user_id, permissions } = req.body; + + console.log("Create user request:", { + username, + email, + is_admin, + is_active, + rocket_chat_user_id, + permissions: permissions || [] + }); + + // Validate required fields + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + + // Check if username is taken + const existingUser = await client.query( + 'SELECT id FROM users WHERE username = $1', + [username] + ); + + if (existingUser.rows.length > 0) { + return res.status(400).json({ error: 'Username already exists' }); + } + + // Start transaction + await client.query('BEGIN'); + + // Hash password + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + // Insert new user + // Convert rocket_chat_user_id to integer if provided + const rcUserId = rocket_chat_user_id ? parseInt(rocket_chat_user_id, 10) : null; + + const userResult = await client.query(` + INSERT INTO users (username, email, password, is_admin, is_active, rocket_chat_user_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP) + RETURNING id + `, [username, email || null, hashedPassword, !!is_admin, is_active !== false, rcUserId]); + + const userId = userResult.rows[0].id; + + // Assign permissions if provided and not admin + if (!is_admin && Array.isArray(permissions) && permissions.length > 0) { + console.log("Adding permissions for new user:", userId); + console.log("Permissions received:", permissions); + + // Check permission format + const permissionIds = permissions.map(p => { + if (typeof p === 'object' && p.id) { + console.log("Permission is an object with ID:", p.id); + return parseInt(p.id, 10); + } else if (typeof p === 'number') { + console.log("Permission is a number:", p); + return p; + } else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) { + console.log("Permission is a string that can be parsed as a number:", p); + return parseInt(p, 10); + } else { + console.log("Unknown permission format:", typeof p, p); + // If it's a permission code, we need to look up the ID + return null; + } + }).filter(id => id !== null); + + console.log("Filtered permission IDs:", permissionIds); + + if (permissionIds.length > 0) { + const permissionValues = permissionIds + .map(permId => `(${userId}, ${permId})`) + .join(','); + + console.log("Inserting permission values:", permissionValues); + + try { + await client.query(` + INSERT INTO user_permissions (user_id, permission_id) + VALUES ${permissionValues} + ON CONFLICT DO NOTHING + `); + console.log("Successfully inserted permissions for new user:", userId); + } catch (err) { + console.error("Error inserting permissions for new user:", err); + throw err; + } + } else { + console.log("No valid permission IDs found to insert for new user"); + } + } else { + console.log("Not adding permissions: is_admin =", is_admin, "permissions array:", Array.isArray(permissions), "length:", permissions ? permissions.length : 0); + } + + await client.query('COMMIT'); + + res.status(201).json({ + id: userId, + message: 'User created successfully' + }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error creating user:', error); + res.status(500).json({ error: 'Server error' }); + } finally { + client.release(); + } +}); + +// Update user +router.put('/users/:id', authenticate, requirePermission('edit:users'), async (req, res) => { + const client = await pool.connect(); + + try { + const userId = req.params.id; + const { username, email, password, is_admin, is_active, rocket_chat_user_id, permissions } = req.body; + + console.log("Update user request:", { + userId, + username, + email, + is_admin, + is_active, + rocket_chat_user_id, + permissions: permissions || [] + }); + + // Check if user exists + const userExists = await client.query( + 'SELECT id FROM users WHERE id = $1', + [userId] + ); + + if (userExists.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + // Start transaction + await client.query('BEGIN'); + + // Build update fields + const updateFields = []; + const updateValues = [userId]; // First parameter is the user ID + let paramIndex = 2; + + if (username !== undefined) { + updateFields.push(`username = $${paramIndex++}`); + updateValues.push(username); + } + + if (email !== undefined) { + updateFields.push(`email = $${paramIndex++}`); + updateValues.push(email || null); + } + + if (is_admin !== undefined) { + updateFields.push(`is_admin = $${paramIndex++}`); + updateValues.push(!!is_admin); + } + + if (is_active !== undefined) { + updateFields.push(`is_active = $${paramIndex++}`); + updateValues.push(!!is_active); + } + + if (rocket_chat_user_id !== undefined) { + updateFields.push(`rocket_chat_user_id = $${paramIndex++}`); + // Convert to integer if not null/undefined, otherwise null + const rcUserId = rocket_chat_user_id ? parseInt(rocket_chat_user_id, 10) : null; + updateValues.push(rcUserId); + } + + // Update password if provided + if (password) { + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + updateFields.push(`password = $${paramIndex++}`); + updateValues.push(hashedPassword); + } + + // Update user if there are fields to update + if (updateFields.length > 0) { + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + await client.query(` + UPDATE users + SET ${updateFields.join(', ')} + WHERE id = $1 + `, updateValues); + } + + // Update permissions if provided + if (Array.isArray(permissions)) { + console.log("Updating permissions for user:", userId); + console.log("Permissions received:", permissions); + + // First remove existing permissions + await client.query( + 'DELETE FROM user_permissions WHERE user_id = $1', + [userId] + ); + console.log("Deleted existing permissions for user:", userId); + + // Add new permissions if any and not admin + const newIsAdmin = is_admin !== undefined ? is_admin : (await client.query('SELECT is_admin FROM users WHERE id = $1', [userId])).rows[0].is_admin; + + console.log("User is admin:", newIsAdmin); + + if (!newIsAdmin && permissions.length > 0) { + console.log("Adding permissions:", permissions); + + // Check permission format + const permissionIds = permissions.map(p => { + if (typeof p === 'object' && p.id) { + console.log("Permission is an object with ID:", p.id); + return parseInt(p.id, 10); + } else if (typeof p === 'number') { + console.log("Permission is a number:", p); + return p; + } else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) { + console.log("Permission is a string that can be parsed as a number:", p); + return parseInt(p, 10); + } else { + console.log("Unknown permission format:", typeof p, p); + // If it's a permission code, we need to look up the ID + return null; + } + }).filter(id => id !== null); + + console.log("Filtered permission IDs:", permissionIds); + + if (permissionIds.length > 0) { + const permissionValues = permissionIds + .map(permId => `(${userId}, ${permId})`) + .join(','); + + console.log("Inserting permission values:", permissionValues); + + try { + await client.query(` + INSERT INTO user_permissions (user_id, permission_id) + VALUES ${permissionValues} + ON CONFLICT DO NOTHING + `); + console.log("Successfully inserted permissions for user:", userId); + } catch (err) { + console.error("Error inserting permissions:", err); + throw err; + } + } else { + console.log("No valid permission IDs found to insert"); + } + } + } + + await client.query('COMMIT'); + + res.json({ message: 'User updated successfully' }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error updating user:', error); + res.status(500).json({ error: 'Server error' }); + } finally { + client.release(); + } +}); + +// Delete user +router.delete('/users/:id', authenticate, requirePermission('delete:users'), async (req, res) => { + try { + const userId = req.params.id; + + // Check that user is not deleting themselves + if (req.user.id === parseInt(userId, 10)) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + // Delete user (this will cascade to user_permissions due to FK constraints) + const result = await pool.query( + 'DELETE FROM users WHERE id = $1 RETURNING id', + [userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ message: 'User deleted successfully' }); + } catch (error) { + console.error('Error deleting user:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get all permissions grouped by category +router.get('/permissions/categories', authenticate, requirePermission('view:users'), async (req, res) => { + try { + const result = await pool.query(` + SELECT category, json_agg( + json_build_object( + 'id', id, + 'name', name, + 'code', code, + 'description', description + ) ORDER BY name + ) as permissions + FROM permissions + GROUP BY category + ORDER BY category + `); + + res.json(result.rows); + } catch (error) { + console.error('Error getting permissions:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get all permissions +router.get('/permissions', authenticate, requirePermission('view:users'), async (req, res) => { + try { + const result = await pool.query(` + SELECT * + FROM permissions + ORDER BY category, name + `); + + res.json(result.rows); + } catch (error) { + console.error('Error getting permissions:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/auth/schema.sql b/inventory-server/auth/schema.sql new file mode 100644 index 0000000..13f1508 --- /dev/null +++ b/inventory-server/auth/schema.sql @@ -0,0 +1,89 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + email VARCHAR UNIQUE, + is_admin BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + last_login TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Sequence and defined type for users table if not exists +CREATE SEQUENCE IF NOT EXISTS users_id_seq; + +-- Create permissions table +CREATE TABLE IF NOT EXISTS "public"."permissions" ( + "id" SERIAL PRIMARY KEY, + "name" varchar NOT NULL UNIQUE, + "code" varchar NOT NULL UNIQUE, + "description" text, + "category" varchar NOT NULL, + "created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + "updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + +-- Create user_permissions junction table +CREATE TABLE IF NOT EXISTS "public"."user_permissions" ( + "user_id" int4 NOT NULL REFERENCES "public"."users"("id") ON DELETE CASCADE, + "permission_id" int4 NOT NULL REFERENCES "public"."permissions"("id") ON DELETE CASCADE, + "created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY ("user_id", "permission_id") +); + +-- Add triggers for updated_at on users and permissions +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_permissions_updated_at ON permissions; +CREATE TRIGGER update_permissions_updated_at + BEFORE UPDATE ON permissions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Insert default permissions by page - only the ones used in application +INSERT INTO permissions (name, code, description, category) VALUES + ('Dashboard Access', 'access:dashboard', 'Can access the Dashboard page', 'Pages'), + ('Products Access', 'access:products', 'Can access the Products page', 'Pages'), + ('Categories Access', 'access:categories', 'Can access the Categories page', 'Pages'), + ('Vendors Access', 'access:vendors', 'Can access the Vendors page', 'Pages'), + ('Analytics Access', 'access:analytics', 'Can access the Analytics page', 'Pages'), + ('Forecasting Access', 'access:forecasting', 'Can access the Forecasting page', 'Pages'), + ('Purchase Orders Access', 'access:purchase_orders', 'Can access the Purchase Orders page', 'Pages'), + ('Import Access', 'access:import', 'Can access the Import page', 'Pages'), + ('Settings Access', 'access:settings', 'Can access the Settings page', 'Pages'), + ('AI Validation Debug Access', 'access:ai_validation_debug', 'Can access the AI Validation Debug page', 'Pages') +ON CONFLICT (code) DO NOTHING; + +-- Settings section permissions +INSERT INTO permissions (name, code, description, category) VALUES + ('Data Management', 'settings:data_management', 'Access to the Data Management settings section', 'Settings'), + ('Stock Management', 'settings:stock_management', 'Access to the Stock Management settings section', 'Settings'), + ('Performance Metrics', 'settings:performance_metrics', 'Access to the Performance Metrics settings section', 'Settings'), + ('Calculation Settings', 'settings:calculation_settings', 'Access to the Calculation Settings section', 'Settings'), + ('Template Management', 'settings:templates', 'Access to the Template Management settings section', 'Settings'), + ('User Management', 'settings:user_management', 'Access to the User Management settings section', 'Settings') +ON CONFLICT (code) DO NOTHING; + +-- Set any existing users as admin +UPDATE users SET is_admin = TRUE WHERE is_admin IS NULL; + +-- Grant all permissions to admin users +INSERT INTO user_permissions (user_id, permission_id) +SELECT u.id, p.id +FROM users u, permissions p +WHERE u.is_admin = TRUE +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/inventory-server/auth/server.js b/inventory-server/auth/server.js new file mode 100644 index 0000000..e9e52fc --- /dev/null +++ b/inventory-server/auth/server.js @@ -0,0 +1,171 @@ +require('dotenv').config({ path: '../.env' }); +const express = require('express'); +const cors = require('cors'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { Pool } = require('pg'); +const morgan = require('morgan'); +const authRoutes = require('./routes'); + +// Log startup configuration +console.log('Starting auth server with config:', { + host: process.env.DB_HOST, + user: process.env.DB_USER, + database: process.env.DB_NAME, + port: process.env.DB_PORT, + auth_port: process.env.AUTH_PORT +}); + +const app = express(); +const port = process.env.AUTH_PORT || 3011; + +// Database configuration +const pool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT, +}); + +// Make pool available globally +global.pool = pool; + +// Middleware +app.use(express.json()); +app.use(morgan('combined')); +app.use(cors({ + origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site'], + credentials: true +})); + +// Login endpoint +app.post('/login', async (req, res) => { + const { username, password } = req.body; + + try { + // Get user from database + const result = await pool.query( + 'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1', + [username] + ); + + const user = result.rows[0]; + + // Check if user exists and password is correct + if (!user || !(await bcrypt.compare(password, user.password))) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + // Check if user is active + if (!user.is_active) { + return res.status(403).json({ error: 'Account is inactive' }); + } + + // Update last login timestamp + await pool.query( + 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1', + [user.id] + ); + + // Generate JWT token + const token = jwt.sign( + { userId: user.id, username: user.username }, + process.env.JWT_SECRET, + { expiresIn: '24h' } + ); + + // Get user permissions for the response + const permissionsResult = await pool.query(` + SELECT code + FROM permissions p + JOIN user_permissions up ON p.id = up.permission_id + WHERE up.user_id = $1 + `, [user.id]); + + const permissions = permissionsResult.rows.map(row => row.code); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + is_admin: user.is_admin, + permissions: user.is_admin ? [] : permissions + } + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// User info endpoint +app.get('/me', async (req, res) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No token provided' }); + } + + try { + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Get user details from database + const userResult = await pool.query( + 'SELECT id, username, email, is_admin, rocket_chat_user_id, is_active FROM users WHERE id = $1', + [decoded.userId] + ); + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + const user = userResult.rows[0]; + + // Get user permissions + let permissions = []; + if (!user.is_admin) { + const permissionsResult = await pool.query(` + SELECT code + FROM permissions p + JOIN user_permissions up ON p.id = up.permission_id + WHERE up.user_id = $1 + `, [user.id]); + + permissions = permissionsResult.rows.map(row => row.code); + } + + res.json({ + id: user.id, + username: user.username, + email: user.email, + rocket_chat_user_id: user.rocket_chat_user_id, + is_admin: user.is_admin, + permissions: permissions + }); + } catch (error) { + console.error('Token verification error:', error); + res.status(401).json({ error: 'Invalid token' }); + } +}); + +// Mount all routes from routes.js +app.use('/', authRoutes); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'healthy' }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Something broke!' }); +}); + +// Start server +app.listen(port, () => { + console.log(`Auth server running on port ${port}`); +}); diff --git a/inventory-server/chat/create-new-database.sql b/inventory-server/chat/create-new-database.sql new file mode 100644 index 0000000..00375e7 --- /dev/null +++ b/inventory-server/chat/create-new-database.sql @@ -0,0 +1,45 @@ +-- PostgreSQL Database Creation Script for New Server +-- Run as: sudo -u postgres psql -f create-new-database.sql + +-- Terminate all connections to the database (if it exists) +SELECT pg_terminate_backend(pid) +FROM pg_stat_activity +WHERE datname = 'rocketchat_converted' AND pid <> pg_backend_pid(); + +-- Drop the database if it exists +DROP DATABASE IF EXISTS rocketchat_converted; + +-- Create fresh database +CREATE DATABASE rocketchat_converted; + +-- Create user (if not exists) - UPDATE PASSWORD BEFORE RUNNING! +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'rocketchat_user') THEN + CREATE USER rocketchat_user WITH PASSWORD 'HKjLgt23gWuPXzEAn3rW'; + END IF; +END $$; + +-- Grant database privileges +GRANT CONNECT ON DATABASE rocketchat_converted TO rocketchat_user; +GRANT CREATE ON DATABASE rocketchat_converted TO rocketchat_user; + +-- Connect to the new database +\c rocketchat_converted; + +-- Grant schema privileges +GRANT CREATE ON SCHEMA public TO rocketchat_user; +GRANT USAGE ON SCHEMA public TO rocketchat_user; + +-- Grant privileges on all future tables and sequences +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rocketchat_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO rocketchat_user; + +-- Display success message +\echo 'Database created successfully!' +\echo 'IMPORTANT: Update the password for rocketchat_user before proceeding' +\echo 'Next steps:' +\echo '1. Update the password in this file' +\echo '2. Run export-chat-data.sh on your current server' +\echo '3. Transfer the exported files to this server' +\echo '4. Run import-chat-data.sh on this server' diff --git a/inventory-server/chat/db-convert/mongo_to_postgres_converter.py b/inventory-server/chat/db-convert/mongo_to_postgres_converter.py new file mode 100644 index 0000000..3641d61 --- /dev/null +++ b/inventory-server/chat/db-convert/mongo_to_postgres_converter.py @@ -0,0 +1,881 @@ +#!/usr/bin/env python3 +""" +MongoDB to PostgreSQL Converter for Rocket.Chat +Converts MongoDB BSON export files to PostgreSQL database + +Usage: + python3 mongo_to_postgres_converter.py \ + --mongo-path db/database/62df06d44234d20001289144 \ + --pg-database rocketchat_converted \ + --pg-user rocketchat_user \ + --pg-password your_password \ + --debug +""" + +import json +import os +import re +import subprocess +import sys +import struct +from datetime import datetime +from pathlib import Path +from typing import Dict, Any, List, Optional +import argparse +import traceback + +# Auto-install dependencies if needed +try: + import bson + import psycopg2 +except ImportError: + print("Installing required packages...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "pymongo", "psycopg2-binary"]) + import bson + import psycopg2 + +class MongoToPostgresConverter: + def __init__(self, mongo_db_path: str, postgres_config: Dict[str, str], debug_mode: bool = False, debug_collections: List[str] = None): + self.mongo_db_path = Path(mongo_db_path) + self.postgres_config = postgres_config + self.debug_mode = debug_mode + self.debug_collections = debug_collections or [] + self.collections = {} + self.schema_info = {} + self.error_log = {} + + def log_debug(self, message: str, collection: str = None): + """Log debug messages if debug mode is enabled and collection is in debug list""" + if self.debug_mode and (not self.debug_collections or collection in self.debug_collections): + print(f"DEBUG: {message}") + + def log_error(self, collection: str, error_type: str, details: str): + """Log detailed error information""" + if collection not in self.error_log: + self.error_log[collection] = [] + self.error_log[collection].append({ + 'type': error_type, + 'details': details, + 'timestamp': datetime.now().isoformat() + }) + + def sample_documents(self, collection_name: str, max_samples: int = 3) -> List[Dict]: + """Sample documents from a collection for debugging""" + if not self.debug_mode or (self.debug_collections and collection_name not in self.debug_collections): + return [] + + print(f"\n🔍 Sampling documents from {collection_name}:") + + bson_file = self.collections[collection_name]['bson_file'] + if bson_file.stat().st_size == 0: + print(" Collection is empty") + return [] + + samples = [] + + try: + with open(bson_file, 'rb') as f: + sample_count = 0 + while sample_count < max_samples: + try: + doc_size = int.from_bytes(f.read(4), byteorder='little') + if doc_size <= 0: + break + f.seek(-4, 1) + doc_bytes = f.read(doc_size) + if len(doc_bytes) != doc_size: + break + + doc = bson.decode(doc_bytes) + samples.append(doc) + sample_count += 1 + + print(f" Sample {sample_count} - Keys: {list(doc.keys())}") + # Show a few key fields with their types and truncated values + for key, value in list(doc.items())[:3]: + value_preview = str(value)[:50] + "..." if len(str(value)) > 50 else str(value) + print(f" {key}: {type(value).__name__} = {value_preview}") + if len(doc) > 3: + print(f" ... and {len(doc) - 3} more fields") + print() + + except (bson.InvalidBSON, struct.error, OSError) as e: + self.log_error(collection_name, 'document_parsing', str(e)) + break + + except Exception as e: + self.log_error(collection_name, 'file_reading', str(e)) + print(f" Error reading collection: {e}") + + return samples + + def discover_collections(self): + """Discover all BSON files and their metadata""" + print("Discovering MongoDB collections...") + + for bson_file in self.mongo_db_path.glob("*.bson"): + collection_name = bson_file.stem + metadata_file = bson_file.with_suffix(".metadata.json") + + # Read metadata if available + metadata = {} + if metadata_file.exists(): + try: + with open(metadata_file, 'r', encoding='utf-8') as f: + metadata = json.load(f) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + print(f"Warning: Could not read metadata for {collection_name}: {e}") + metadata = {} + + # Get file size and document count estimate + file_size = bson_file.stat().st_size + doc_count = self._estimate_document_count(bson_file) + + self.collections[collection_name] = { + 'bson_file': bson_file, + 'metadata': metadata, + 'file_size': file_size, + 'estimated_docs': doc_count + } + + print(f"Found {len(self.collections)} collections") + for name, info in self.collections.items(): + print(f" - {name}: {info['file_size']/1024/1024:.1f}MB (~{info['estimated_docs']} docs)") + + def _estimate_document_count(self, bson_file: Path) -> int: + """Estimate document count by reading first few documents""" + if bson_file.stat().st_size == 0: + return 0 + + try: + with open(bson_file, 'rb') as f: + docs_sampled = 0 + bytes_sampled = 0 + max_sample_size = min(1024 * 1024, bson_file.stat().st_size) # 1MB or file size + + while bytes_sampled < max_sample_size: + try: + doc_size = int.from_bytes(f.read(4), byteorder='little') + if doc_size <= 0 or doc_size > 16 * 1024 * 1024: # MongoDB doc size limit + break + f.seek(-4, 1) # Go back + doc_bytes = f.read(doc_size) + if len(doc_bytes) != doc_size: + break + bson.decode(doc_bytes) # Validate it's a valid BSON document + docs_sampled += 1 + bytes_sampled += doc_size + except (bson.InvalidBSON, struct.error, OSError): + break + + if docs_sampled > 0 and bytes_sampled > 0: + avg_doc_size = bytes_sampled / docs_sampled + return int(bson_file.stat().st_size / avg_doc_size) + + except Exception: + pass + + return 0 + + def analyze_schema(self, collection_name: str, sample_size: int = 100) -> Dict[str, Any]: + """Analyze collection schema by sampling documents""" + print(f"Analyzing schema for {collection_name}...") + + bson_file = self.collections[collection_name]['bson_file'] + if bson_file.stat().st_size == 0: + return {} + + schema = {} + docs_analyzed = 0 + + try: + with open(bson_file, 'rb') as f: + while docs_analyzed < sample_size: + try: + doc_size = int.from_bytes(f.read(4), byteorder='little') + if doc_size <= 0: + break + f.seek(-4, 1) + doc_bytes = f.read(doc_size) + if len(doc_bytes) != doc_size: + break + + doc = bson.decode(doc_bytes) + self._analyze_document_schema(doc, schema) + docs_analyzed += 1 + + except (bson.InvalidBSON, struct.error, OSError): + break + + except Exception as e: + print(f"Error analyzing {collection_name}: {e}") + + self.schema_info[collection_name] = schema + return schema + + def _analyze_document_schema(self, doc: Dict[str, Any], schema: Dict[str, Any], prefix: str = ""): + """Recursively analyze document structure""" + for key, value in doc.items(): + full_key = f"{prefix}.{key}" if prefix else key + + if full_key not in schema: + schema[full_key] = { + 'types': set(), + 'null_count': 0, + 'total_count': 0, + 'is_array': False, + 'nested_schema': {} + } + + schema[full_key]['total_count'] += 1 + + if value is None: + schema[full_key]['null_count'] += 1 + schema[full_key]['types'].add('null') + elif isinstance(value, dict): + schema[full_key]['types'].add('object') + if 'nested_schema' not in schema[full_key]: + schema[full_key]['nested_schema'] = {} + self._analyze_document_schema(value, schema[full_key]['nested_schema']) + elif isinstance(value, list): + schema[full_key]['types'].add('array') + schema[full_key]['is_array'] = True + if value and isinstance(value[0], dict): + if 'array_item_schema' not in schema[full_key]: + schema[full_key]['array_item_schema'] = {} + for item in value[:5]: # Sample first 5 items + if isinstance(item, dict): + self._analyze_document_schema(item, schema[full_key]['array_item_schema']) + else: + schema[full_key]['types'].add(type(value).__name__) + + def generate_postgres_schema(self) -> Dict[str, str]: + """Generate PostgreSQL CREATE TABLE statements""" + print("Generating PostgreSQL schema...") + + table_definitions = {} + + for collection_name, schema in self.schema_info.items(): + if not schema: # Empty collection + continue + + table_name = self._sanitize_table_name(collection_name) + columns = [] + + # Always add an id column (PostgreSQL doesn't use _id like MongoDB) + columns.append("id SERIAL PRIMARY KEY") + + for field_name, field_info in schema.items(): + if field_name == '_id': + columns.append("mongo_id TEXT") # Always allow NULL for mongo_id + continue + + col_name = self._sanitize_column_name(field_name) + + # Handle conflicts with PostgreSQL auto-generated columns + if col_name in ['id', 'mongo_id', 'created_at', 'updated_at']: + col_name = f"field_{col_name}" + + col_type = self._determine_postgres_type(field_info) + + # Make all fields nullable by default to avoid constraint violations + columns.append(f"{col_name} {col_type}") + + # Add metadata columns + columns.extend([ + "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP", + "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + ]) + + column_definitions = ',\n '.join(columns) + table_sql = f""" +CREATE TABLE IF NOT EXISTS {table_name} ( + {column_definitions} +); + +-- Create indexes based on MongoDB indexes +""" + + # Get list of actual columns that will exist in the table + existing_columns = set(['id', 'mongo_id', 'created_at', 'updated_at']) + for field_name in schema.keys(): + if field_name != '_id': + col_name = self._sanitize_column_name(field_name) + # Handle conflicts with PostgreSQL auto-generated columns + if col_name in ['id', 'mongo_id', 'created_at', 'updated_at']: + col_name = f"field_{col_name}" + existing_columns.add(col_name) + + # Add indexes from MongoDB metadata + metadata = self.collections[collection_name].get('metadata', {}) + indexes = metadata.get('indexes', []) + + for index in indexes: + if index['name'] != '_id_': # Skip the default _id index + # Sanitize index name - remove special characters + sanitized_index_name = re.sub(r'[^a-zA-Z0-9_]', '_', index['name']) + index_name = f"idx_{table_name}_{sanitized_index_name}" + index_keys = list(index['key'].keys()) + if index_keys: + sanitized_keys = [] + for key in index_keys: + if key != '_id': + sanitized_key = self._sanitize_column_name(key) + # Handle conflicts with PostgreSQL auto-generated columns + if sanitized_key in ['id', 'mongo_id', 'created_at', 'updated_at']: + sanitized_key = f"field_{sanitized_key}" + # Only add if the column actually exists in our table + if sanitized_key in existing_columns: + sanitized_keys.append(sanitized_key) + + if sanitized_keys: + table_sql += f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name} ({', '.join(sanitized_keys)});\n" + + table_definitions[collection_name] = table_sql + + return table_definitions + + def _sanitize_table_name(self, name: str) -> str: + """Convert MongoDB collection name to PostgreSQL table name""" + # Remove rocketchat_ prefix if present + if name.startswith('rocketchat_'): + name = name[11:] + + # Replace special characters with underscores + name = re.sub(r'[^a-zA-Z0-9_]', '_', name) + + # Ensure it starts with a letter + if name and name[0].isdigit(): + name = 'table_' + name + + return name.lower() + + def _sanitize_column_name(self, name: str) -> str: + """Convert MongoDB field name to PostgreSQL column name""" + # Handle nested field names (convert dots to underscores) + name = name.replace('.', '_') + + # Replace special characters with underscores + name = re.sub(r'[^a-zA-Z0-9_]', '_', name) + + # Ensure it starts with a letter or underscore + if name and name[0].isdigit(): + name = 'col_' + name + + # Handle PostgreSQL reserved words + reserved = { + 'user', 'order', 'group', 'table', 'index', 'key', 'value', 'date', 'time', 'timestamp', + 'default', 'select', 'from', 'where', 'insert', 'update', 'delete', 'create', 'drop', + 'alter', 'grant', 'revoke', 'commit', 'rollback', 'begin', 'end', 'case', 'when', + 'then', 'else', 'if', 'null', 'not', 'and', 'or', 'in', 'exists', 'between', + 'like', 'limit', 'offset', 'union', 'join', 'inner', 'outer', 'left', 'right', + 'full', 'cross', 'natural', 'on', 'using', 'distinct', 'all', 'any', 'some', + 'desc', 'asc', 'primary', 'foreign', 'references', 'constraint', 'unique', + 'check', 'cascade', 'restrict', 'action', 'match', 'partial', 'full' + } + if name.lower() in reserved: + name = name + '_col' + + return name.lower() + + def _determine_postgres_type(self, field_info: Dict[str, Any]) -> str: + """Determine PostgreSQL column type from MongoDB field analysis with improved logic""" + types = field_info['types'] + + # Convert set to list for easier checking + type_list = list(types) + + # If there's only one type (excluding null), use specific typing + non_null_types = [t for t in type_list if t != 'null'] + + if len(non_null_types) == 1: + single_type = non_null_types[0] + if single_type == 'bool': + return 'BOOLEAN' + elif single_type == 'int': + return 'INTEGER' + elif single_type == 'float': + return 'NUMERIC' + elif single_type == 'str': + return 'TEXT' + elif single_type == 'datetime': + return 'TIMESTAMP' + elif single_type == 'ObjectId': + return 'TEXT' + + # Handle mixed types more conservatively + if 'array' in types or field_info.get('is_array', False): + return 'JSONB' # Arrays always go to JSONB + elif 'object' in types: + return 'JSONB' # Objects always go to JSONB + elif len(non_null_types) > 1: + # Multiple non-null types - check for common combinations + if set(non_null_types) <= {'int', 'float'}: + return 'NUMERIC' # Can handle both int and float + elif set(non_null_types) <= {'bool', 'str'}: + return 'TEXT' # Convert everything to text + elif set(non_null_types) <= {'str', 'ObjectId'}: + return 'TEXT' # Both are string-like + else: + return 'JSONB' # Complex mixed types go to JSONB + elif 'ObjectId' in types: + return 'TEXT' + elif 'datetime' in types: + return 'TIMESTAMP' + elif 'bool' in types: + return 'BOOLEAN' + elif 'int' in types: + return 'INTEGER' + elif 'float' in types: + return 'NUMERIC' + elif 'str' in types: + return 'TEXT' + else: + return 'TEXT' # Default fallback + + def create_postgres_database(self, table_definitions: Dict[str, str]): + """Create PostgreSQL database and tables""" + print("Creating PostgreSQL database schema...") + + try: + # Connect to PostgreSQL + conn = psycopg2.connect(**self.postgres_config) + conn.autocommit = True + cursor = conn.cursor() + + # Create tables + for collection_name, table_sql in table_definitions.items(): + print(f"Creating table for {collection_name}...") + cursor.execute(table_sql) + + cursor.close() + conn.close() + print("Database schema created successfully!") + + except Exception as e: + print(f"Error creating database schema: {e}") + raise + + def convert_and_insert_data(self, batch_size: int = 1000): + """Convert BSON data and insert into PostgreSQL""" + print("Converting and inserting data...") + + try: + conn = psycopg2.connect(**self.postgres_config) + conn.autocommit = False + + for collection_name in self.collections: + print(f"Processing {collection_name}...") + self._convert_collection(conn, collection_name, batch_size) + + conn.close() + print("Data conversion completed successfully!") + + except Exception as e: + print(f"Error converting data: {e}") + raise + + def _convert_collection(self, conn, collection_name: str, batch_size: int): + """Convert a single collection""" + bson_file = self.collections[collection_name]['bson_file'] + + if bson_file.stat().st_size == 0: + print(f" Skipping empty collection {collection_name}") + return + + table_name = self._sanitize_table_name(collection_name) + cursor = conn.cursor() + + batch = [] + total_inserted = 0 + errors = 0 + + try: + with open(bson_file, 'rb') as f: + while True: + try: + doc_size = int.from_bytes(f.read(4), byteorder='little') + if doc_size <= 0: + break + f.seek(-4, 1) + doc_bytes = f.read(doc_size) + if len(doc_bytes) != doc_size: + break + + doc = bson.decode(doc_bytes) + batch.append(doc) + + if len(batch) >= batch_size: + inserted, batch_errors = self._insert_batch(cursor, table_name, batch, collection_name) + total_inserted += inserted + errors += batch_errors + batch = [] + conn.commit() + if total_inserted % 5000 == 0: # Less frequent progress updates + print(f" Inserted {total_inserted} documents...") + + except (bson.InvalidBSON, struct.error, OSError): + break + + # Insert remaining documents + if batch: + inserted, batch_errors = self._insert_batch(cursor, table_name, batch, collection_name) + total_inserted += inserted + errors += batch_errors + conn.commit() + + if errors > 0: + print(f" Completed {collection_name}: {total_inserted} documents inserted ({errors} errors)") + else: + print(f" Completed {collection_name}: {total_inserted} documents inserted") + + except Exception as e: + print(f" Error processing {collection_name}: {e}") + conn.rollback() + finally: + cursor.close() + + def _insert_batch(self, cursor, table_name: str, documents: List[Dict], collection_name: str): + """Insert a batch of documents with proper transaction handling""" + if not documents: + return 0, 0 + + # Get schema info for this collection + schema = self.schema_info.get(collection_name, {}) + + # Build column list + columns = ['mongo_id'] + for field_name in schema.keys(): + if field_name != '_id': + col_name = self._sanitize_column_name(field_name) + # Handle conflicts with PostgreSQL auto-generated columns + if col_name in ['id', 'mongo_id', 'created_at', 'updated_at']: + col_name = f"field_{col_name}" + columns.append(col_name) + + # Build INSERT statement + placeholders = ', '.join(['%s'] * len(columns)) + sql = f"INSERT INTO {table_name} ({', '.join(columns)}) VALUES ({placeholders})" + + self.log_debug(f"SQL: {sql}", collection_name) + + # Convert documents to tuples + rows = [] + errors = 0 + + for doc_idx, doc in enumerate(documents): + try: + row = [] + + # Add mongo_id + row.append(str(doc.get('_id', ''))) + + # Add other fields + for field_name in schema.keys(): + if field_name != '_id': + try: + value = self._get_nested_value(doc, field_name) + converted_value = self._convert_value_for_postgres(value, field_name, schema) + row.append(converted_value) + except Exception as e: + self.log_error(collection_name, 'field_conversion', + f"Field '{field_name}' in doc {doc_idx}: {str(e)}") + # Only show debug for collections we're focusing on + if collection_name in self.debug_collections: + print(f" ⚠️ Error converting field '{field_name}': {e}") + row.append(None) # Use NULL for problematic fields + + rows.append(tuple(row)) + + except Exception as e: + self.log_error(collection_name, 'document_conversion', f"Document {doc_idx}: {str(e)}") + errors += 1 + continue + + # Execute batch insert + if rows: + try: + cursor.executemany(sql, rows) + return len(rows), errors + except Exception as batch_error: + self.log_error(collection_name, 'batch_insert', str(batch_error)) + + # Only show detailed debugging for targeted collections + if collection_name in self.debug_collections: + print(f" 🔴 Batch insert failed for {collection_name}: {batch_error}") + print(" Trying individual inserts with rollback handling...") + + # Rollback the failed transaction + cursor.connection.rollback() + + # Try inserting one by one in individual transactions + success_count = 0 + for row_idx, row in enumerate(rows): + try: + cursor.execute(sql, row) + cursor.connection.commit() # Commit each successful insert + success_count += 1 + except Exception as row_error: + cursor.connection.rollback() # Rollback failed insert + self.log_error(collection_name, 'row_insert', f"Row {row_idx}: {str(row_error)}") + + # Show detailed error only for the first few failures and only for targeted collections + if collection_name in self.debug_collections and errors < 3: + print(f" Row {row_idx} failed: {row_error}") + print(f" Row data: {len(row)} values, expected {len(columns)} columns") + + errors += 1 + continue + return success_count, errors + + return 0, errors + + def _get_nested_value(self, doc: Dict, field_path: str): + """Get value from nested document using dot notation""" + keys = field_path.split('.') + value = doc + + for key in keys: + if isinstance(value, dict) and key in value: + value = value[key] + else: + return None + + return value + + def _convert_value_for_postgres(self, value, field_name: str = None, schema: Dict = None): + """Convert MongoDB value to PostgreSQL compatible value with schema-aware conversion""" + if value is None: + return None + + # Get the expected PostgreSQL type for this field if available + expected_type = None + if schema and field_name and field_name in schema: + field_info = schema[field_name] + expected_type = self._determine_postgres_type(field_info) + + # Handle conversion based on expected type + if expected_type == 'BOOLEAN': + if isinstance(value, bool): + return value + elif isinstance(value, str): + return value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(value, (int, float)): + return bool(value) + else: + return None + elif expected_type == 'INTEGER': + if isinstance(value, int): + return value + elif isinstance(value, float): + return int(value) + elif isinstance(value, str) and value.isdigit(): + return int(value) + elif isinstance(value, bool): + return int(value) + else: + return None + elif expected_type == 'NUMERIC': + if isinstance(value, (int, float)): + return value + elif isinstance(value, str): + try: + return float(value) + except ValueError: + return None + elif isinstance(value, bool): + return float(value) + else: + return None + elif expected_type == 'TEXT': + if isinstance(value, str): + return value + elif value is not None: + str_value = str(value) + # Handle very long strings + if len(str_value) > 65535: + return str_value[:65535] + return str_value + else: + return None + elif expected_type == 'TIMESTAMP': + if hasattr(value, 'isoformat'): + return value.isoformat() + elif isinstance(value, str): + return value + else: + return str(value) if value is not None else None + elif expected_type == 'JSONB': + if isinstance(value, (dict, list)): + return json.dumps(value, default=self._json_serializer) + elif isinstance(value, str): + # Check if it's already valid JSON + try: + json.loads(value) + return value + except (json.JSONDecodeError, TypeError): + # Not valid JSON, wrap it + return json.dumps(value) + else: + return json.dumps(value, default=self._json_serializer) + + # Fallback to original logic if no expected type or type not recognized + if isinstance(value, bool): + return value + elif isinstance(value, (int, float)): + return value + elif isinstance(value, str): + return value + elif isinstance(value, (dict, list)): + return json.dumps(value, default=self._json_serializer) + elif hasattr(value, 'isoformat'): # datetime + return value.isoformat() + elif hasattr(value, '__str__'): + str_value = str(value) + if len(str_value) > 65535: + return str_value[:65535] + return str_value + else: + return str(value) + + def _json_serializer(self, obj): + """Custom JSON serializer for complex objects with better error handling""" + try: + if hasattr(obj, 'isoformat'): # datetime + return obj.isoformat() + elif hasattr(obj, '__str__'): + return str(obj) + else: + return None + except Exception as e: + self.log_debug(f"JSON serialization error: {e}") + return str(obj) + + def run_conversion(self, sample_size: int = 100, batch_size: int = 1000): + """Run the full conversion process with focused debugging""" + print("Starting MongoDB to PostgreSQL conversion...") + print("This will convert your Rocket.Chat database from MongoDB to PostgreSQL") + if self.debug_mode: + if self.debug_collections: + print(f"🐛 DEBUG MODE: Focusing on collections: {', '.join(self.debug_collections)}") + else: + print("🐛 DEBUG MODE: All collections") + print("=" * 70) + + # Step 1: Discover collections + self.discover_collections() + + # Step 2: Analyze schemas + print("\nAnalyzing collection schemas...") + for collection_name in self.collections: + self.analyze_schema(collection_name, sample_size) + + # Sample problematic collections if debugging + if self.debug_mode and self.debug_collections: + for coll in self.debug_collections: + if coll in self.collections: + self.sample_documents(coll, 2) + + # Step 3: Generate PostgreSQL schema + table_definitions = self.generate_postgres_schema() + + # Step 4: Create database schema + self.create_postgres_database(table_definitions) + + # Step 5: Convert and insert data + self.convert_and_insert_data(batch_size) + + # Step 6: Show error summary + self._print_error_summary() + + print("=" * 70) + print("✅ Conversion completed!") + print(f" Database: {self.postgres_config['database']}") + print(f" Tables created: {len(table_definitions)}") + + def _print_error_summary(self): + """Print a focused summary of errors""" + if not self.error_log: + print("\n✅ No errors encountered during conversion!") + return + + print("\n⚠️ ERROR SUMMARY:") + print("=" * 50) + + # Sort by error count descending + sorted_collections = sorted(self.error_log.items(), + key=lambda x: len(x[1]), reverse=True) + + for collection, errors in sorted_collections: + error_types = {} + for error in errors: + error_type = error['type'] + if error_type not in error_types: + error_types[error_type] = [] + error_types[error_type].append(error['details']) + + print(f"\n🔴 {collection} ({len(errors)} total errors):") + for error_type, details_list in error_types.items(): + print(f" {error_type}: {len(details_list)} errors") + + # Show sample errors for critical collections + if collection in ['rocketchat_settings', 'rocketchat_room'] and len(details_list) > 0: + print(f" Sample: {details_list[0][:100]}...") + +def main(): + parser = argparse.ArgumentParser( + description='Convert MongoDB BSON export to PostgreSQL', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic usage + python3 mongo_to_postgres_converter.py \\ + --mongo-path db/database/62df06d44234d20001289144 \\ + --pg-database rocketchat_converted \\ + --pg-user rocketchat_user \\ + --pg-password mypassword + + # Debug specific failing collections + python3 mongo_to_postgres_converter.py \\ + --mongo-path db/database/62df06d44234d20001289144 \\ + --pg-database rocketchat_converted \\ + --pg-user rocketchat_user \\ + --pg-password mypassword \\ + --debug-collections rocketchat_settings rocketchat_room + +Before running this script: +1. Run: sudo -u postgres psql -f reset_database.sql +2. Update the password in reset_database.sql + """ + ) + + parser.add_argument('--mongo-path', required=True, help='Path to MongoDB export directory') + parser.add_argument('--pg-host', default='localhost', help='PostgreSQL host (default: localhost)') + parser.add_argument('--pg-port', default='5432', help='PostgreSQL port (default: 5432)') + parser.add_argument('--pg-database', required=True, help='PostgreSQL database name') + parser.add_argument('--pg-user', required=True, help='PostgreSQL username') + parser.add_argument('--pg-password', required=True, help='PostgreSQL password') + parser.add_argument('--sample-size', type=int, default=100, help='Number of documents to sample for schema analysis (default: 100)') + parser.add_argument('--batch-size', type=int, default=1000, help='Batch size for data insertion (default: 1000)') + parser.add_argument('--debug', action='store_true', help='Enable debug mode with detailed error logging') + parser.add_argument('--debug-collections', nargs='*', help='Specific collections to debug (e.g., rocketchat_settings rocketchat_room)') + + args = parser.parse_args() + + postgres_config = { + 'host': args.pg_host, + 'port': args.pg_port, + 'database': args.pg_database, + 'user': args.pg_user, + 'password': args.pg_password + } + + # Enable debug mode if debug collections are specified + debug_mode = args.debug or (args.debug_collections is not None) + + converter = MongoToPostgresConverter(args.mongo_path, postgres_config, debug_mode, args.debug_collections) + converter.run_conversion(args.sample_size, args.batch_size) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/inventory-server/chat/db-convert/reset_database.sql b/inventory-server/chat/db-convert/reset_database.sql new file mode 100644 index 0000000..7a91e95 --- /dev/null +++ b/inventory-server/chat/db-convert/reset_database.sql @@ -0,0 +1,41 @@ +-- PostgreSQL Database Reset Script for Rocket.Chat Import +-- Run as: sudo -u postgres psql -f reset_database.sql + +-- Terminate all connections to the database (force disconnect users) +SELECT pg_terminate_backend(pid) +FROM pg_stat_activity +WHERE datname = 'rocketchat_converted' AND pid <> pg_backend_pid(); + +-- Drop the database if it exists +DROP DATABASE IF EXISTS rocketchat_converted; + +-- Create fresh database +CREATE DATABASE rocketchat_converted; + +-- Create user (if not exists) +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'rocketchat_user') THEN + CREATE USER rocketchat_user WITH PASSWORD 'HKjLgt23gWuPXzEAn3rW'; + END IF; +END $$; + +-- Grant database privileges +GRANT CONNECT ON DATABASE rocketchat_converted TO rocketchat_user; +GRANT CREATE ON DATABASE rocketchat_converted TO rocketchat_user; + +-- Connect to the new database +\c rocketchat_converted; + +-- Grant schema privileges +GRANT CREATE ON SCHEMA public TO rocketchat_user; +GRANT USAGE ON SCHEMA public TO rocketchat_user; + +-- Grant privileges on all future tables and sequences +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rocketchat_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO rocketchat_user; + +-- Display success message +\echo 'Database reset completed successfully!' +\echo 'You can now run the converter with:' +\echo 'python3 mongo_to_postgres_converter.py --mongo-path db/database/62df06d44234d20001289144 --pg-database rocketchat_converted --pg-user rocketchat_user --pg-password your_password' \ No newline at end of file diff --git a/inventory-server/chat/db-convert/test_converter.py b/inventory-server/chat/db-convert/test_converter.py new file mode 100644 index 0000000..071b5c9 --- /dev/null +++ b/inventory-server/chat/db-convert/test_converter.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Quick test script to verify the converter fixes work for problematic collections +""" + +from mongo_to_postgres_converter import MongoToPostgresConverter + +def test_problematic_collections(): + print("🧪 Testing converter fixes for problematic collections...") + + postgres_config = { + 'host': 'localhost', + 'port': '5432', + 'database': 'rocketchat_test', + 'user': 'rocketchat_user', + 'password': 'password123' + } + + converter = MongoToPostgresConverter( + 'db/database/62df06d44234d20001289144', + postgres_config, + debug_mode=True, + debug_collections=['rocketchat_settings', 'rocketchat_room'] + ) + + # Test just discovery and schema analysis + print("\n1. Testing collection discovery...") + converter.discover_collections() + + print("\n2. Testing schema analysis...") + if 'rocketchat_settings' in converter.collections: + settings_schema = converter.analyze_schema('rocketchat_settings', 10) + print(f"Settings schema fields: {len(settings_schema)}") + + # Check specific problematic fields + if 'packageValue' in settings_schema: + packagevalue_info = settings_schema['packageValue'] + pg_type = converter._determine_postgres_type(packagevalue_info) + print(f"packageValue types: {packagevalue_info['types']} -> PostgreSQL: {pg_type}") + + if 'rocketchat_room' in converter.collections: + room_schema = converter.analyze_schema('rocketchat_room', 10) + print(f"Room schema fields: {len(room_schema)}") + + # Check specific problematic fields + if 'sysMes' in room_schema: + sysmes_info = room_schema['sysMes'] + pg_type = converter._determine_postgres_type(sysmes_info) + print(f"sysMes types: {sysmes_info['types']} -> PostgreSQL: {pg_type}") + + print("\n✅ Test completed - check the type mappings above!") + +if __name__ == '__main__': + test_problematic_collections() \ No newline at end of file diff --git a/inventory-server/chat/export-chat-data.sh b/inventory-server/chat/export-chat-data.sh new file mode 100755 index 0000000..d6ab0ef --- /dev/null +++ b/inventory-server/chat/export-chat-data.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Chat Database Export Script +# This script exports the chat database schema and data for migration + +set -e # Exit on any error + +echo "🚀 Starting chat database export..." + +# Configuration - Update these values for your setup +DB_HOST="${CHAT_DB_HOST:-localhost}" +DB_PORT="${CHAT_DB_PORT:-5432}" +DB_NAME="${CHAT_DB_NAME:-rocketchat_converted}" +DB_USER="${CHAT_DB_USER:-rocketchat_user}" + +# Check if database connection info is available +if [ -z "$CHAT_DB_PASSWORD" ]; then + echo "⚠️ CHAT_DB_PASSWORD environment variable not set" + echo "Please set it with: export CHAT_DB_PASSWORD='your_password'" + exit 1 +fi + +echo "📊 Database: $DB_NAME on $DB_HOST:$DB_PORT" + +# Create export directory +EXPORT_DIR="chat-migration-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$EXPORT_DIR" + +echo "📁 Export directory: $EXPORT_DIR" + +# Export database schema +echo "📋 Exporting database schema..." +PGPASSWORD="$CHAT_DB_PASSWORD" pg_dump \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + --schema-only \ + --no-owner \ + --no-privileges \ + -f "$EXPORT_DIR/chat-schema.sql" + +if [ $? -eq 0 ]; then + echo "✅ Schema exported successfully" +else + echo "❌ Schema export failed" + exit 1 +fi + +# Export database data +echo "💾 Exporting database data..." +PGPASSWORD="$CHAT_DB_PASSWORD" pg_dump \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + --data-only \ + --no-owner \ + --no-privileges \ + --disable-triggers \ + --column-inserts \ + -f "$EXPORT_DIR/chat-data.sql" + +if [ $? -eq 0 ]; then + echo "✅ Data exported successfully" +else + echo "❌ Data export failed" + exit 1 +fi + +# Export file uploads and avatars +echo "📎 Exporting chat files (uploads and avatars)..." +if [ -d "db-convert/db/files" ]; then + cd db-convert/db + tar -czf "../../$EXPORT_DIR/chat-files.tar.gz" files/ + cd ../.. + echo "✅ Files exported successfully" +else + echo "⚠️ No files directory found at db-convert/db/files" + echo " This is normal if you have no file uploads" + touch "$EXPORT_DIR/chat-files.tar.gz" +fi + +# Get table statistics for verification +echo "📈 Generating export statistics..." +PGPASSWORD="$CHAT_DB_PASSWORD" psql \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + -c " + SELECT + schemaname, + tablename, + n_tup_ins as inserted_rows, + n_tup_upd as updated_rows, + n_tup_del as deleted_rows, + n_live_tup as live_rows, + n_dead_tup as dead_rows + FROM pg_stat_user_tables + ORDER BY n_live_tup DESC; + " > "$EXPORT_DIR/table-stats.txt" + +# Create export summary +cat > "$EXPORT_DIR/export-summary.txt" << EOF +Chat Database Export Summary +=========================== + +Export Date: $(date) +Database: $DB_NAME +Host: $DB_HOST:$DB_PORT +User: $DB_USER + +Files Generated: +- chat-schema.sql: Database schema (tables, indexes, constraints) +- chat-data.sql: All table data +- chat-files.tar.gz: Uploaded files and avatars +- table-stats.txt: Database statistics +- export-summary.txt: This summary + +Next Steps: +1. Transfer these files to your new server +2. Run create-new-database.sql on the new server first +3. Run import-chat-data.sh on the new server +4. Update your application configuration +5. Run verify-migration.js to validate the migration + +Important Notes: +- Keep these files secure as they contain your chat data +- Ensure the new server has enough disk space +- Plan for application downtime during the migration +EOF + +echo "" +echo "🎉 Export completed successfully!" +echo "📁 Files are in: $EXPORT_DIR/" +echo "" +echo "📋 Export Summary:" +ls -lh "$EXPORT_DIR/" +echo "" +echo "🚚 Next steps:" +echo "1. Transfer the $EXPORT_DIR/ directory to your new server" +echo "2. Run create-new-database.sql on the new server (update password first!)" +echo "3. Run import-chat-data.sh on the new server" +echo "" +echo "💡 To transfer files to new server:" +echo " scp -r $EXPORT_DIR/ user@new-server:/tmp/" diff --git a/inventory-server/chat/import-chat-data.sh b/inventory-server/chat/import-chat-data.sh new file mode 100755 index 0000000..eb8a123 --- /dev/null +++ b/inventory-server/chat/import-chat-data.sh @@ -0,0 +1,167 @@ +#!/bin/bash + +# Chat Database Import Script +# This script imports the chat database schema and data on the new server + +set -e # Exit on any error + +echo "🚀 Starting chat database import..." + +# Configuration - Update these values for your new server +DB_HOST="${CHAT_DB_HOST:-localhost}" +DB_PORT="${CHAT_DB_PORT:-5432}" +DB_NAME="${CHAT_DB_NAME:-rocketchat_converted}" +DB_USER="${CHAT_DB_USER:-rocketchat_user}" + +# Check if database connection info is available +if [ -z "$CHAT_DB_PASSWORD" ]; then + echo "⚠️ CHAT_DB_PASSWORD environment variable not set" + echo "Please set it with: export CHAT_DB_PASSWORD='your_password'" + exit 1 +fi + +# Find the migration directory +MIGRATION_DIR="" +if [ -d "/tmp" ]; then + MIGRATION_DIR=$(find /tmp -maxdepth 1 -name "chat-migration-*" -type d | head -1) +fi + +if [ -z "$MIGRATION_DIR" ]; then + echo "❌ No migration directory found in /tmp/" + echo "Please specify the migration directory:" + read -p "Enter full path to migration directory: " MIGRATION_DIR +fi + +if [ ! -d "$MIGRATION_DIR" ]; then + echo "❌ Migration directory not found: $MIGRATION_DIR" + exit 1 +fi + +echo "📁 Using migration directory: $MIGRATION_DIR" +echo "📊 Target database: $DB_NAME on $DB_HOST:$DB_PORT" + +# Verify required files exist +REQUIRED_FILES=("chat-schema.sql" "chat-data.sql" "chat-files.tar.gz") +for file in "${REQUIRED_FILES[@]}"; do + if [ ! -f "$MIGRATION_DIR/$file" ]; then + echo "❌ Required file not found: $MIGRATION_DIR/$file" + exit 1 + fi +done + +echo "✅ All required files found" + +# Test database connection +echo "🔗 Testing database connection..." +PGPASSWORD="$CHAT_DB_PASSWORD" psql \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + -c "SELECT version();" > /dev/null + +if [ $? -eq 0 ]; then + echo "✅ Database connection successful" +else + echo "❌ Database connection failed" + echo "Please ensure:" + echo " 1. PostgreSQL is running" + echo " 2. Database '$DB_NAME' exists" + echo " 3. User '$DB_USER' has access" + echo " 4. Password is correct" + exit 1 +fi + +# Import database schema +echo "📋 Importing database schema..." +PGPASSWORD="$CHAT_DB_PASSWORD" psql \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + -f "$MIGRATION_DIR/chat-schema.sql" + +if [ $? -eq 0 ]; then + echo "✅ Schema imported successfully" +else + echo "❌ Schema import failed" + exit 1 +fi + +# Import database data +echo "💾 Importing database data..." +echo " This may take a while depending on data size..." + +PGPASSWORD="$CHAT_DB_PASSWORD" psql \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + -f "$MIGRATION_DIR/chat-data.sql" + +if [ $? -eq 0 ]; then + echo "✅ Data imported successfully" +else + echo "❌ Data import failed" + echo "Check the error messages above for details" + exit 1 +fi + +# Create files directory and import files +echo "📎 Setting up files directory..." +mkdir -p "db-convert/db" + +if [ -s "$MIGRATION_DIR/chat-files.tar.gz" ]; then + echo "📂 Extracting chat files..." + cd db-convert/db + tar -xzf "$MIGRATION_DIR/chat-files.tar.gz" + cd ../.. + + # Set proper permissions + if [ -d "db-convert/db/files" ]; then + chmod -R 755 db-convert/db/files + echo "✅ Files imported and permissions set" + else + echo "⚠️ Files directory not created properly" + fi +else + echo "ℹ️ No files to import (empty archive)" + mkdir -p "db-convert/db/files/uploads" + mkdir -p "db-convert/db/files/avatars" +fi + +# Get final table statistics +echo "📈 Generating import statistics..." +PGPASSWORD="$CHAT_DB_PASSWORD" psql \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + -c " + SELECT + tablename, + n_live_tup as row_count + FROM pg_stat_user_tables + WHERE schemaname = 'public' + ORDER BY n_live_tup DESC; + " + +# Create import summary +echo "" +echo "🎉 Import completed successfully!" +echo "" +echo "📋 Import Summary:" +echo " Database: $DB_NAME" +echo " Host: $DB_HOST:$DB_PORT" +echo " Files location: $(pwd)/db-convert/db/files/" +echo "" +echo "🔍 Next steps:" +echo "1. Update your application configuration to use this database" +echo "2. Run verify-migration.js to validate the migration" +echo "3. Test your application thoroughly" +echo "4. Update DNS/load balancer to point to new server" +echo "" +echo "⚠️ Important:" +echo "- Keep the original data as backup until migration is fully validated" +echo "- Monitor the application closely after switching" +echo "- Have a rollback plan ready" diff --git a/inventory-server/chat/migrate-to-new-server.md b/inventory-server/chat/migrate-to-new-server.md new file mode 100644 index 0000000..f3e75aa --- /dev/null +++ b/inventory-server/chat/migrate-to-new-server.md @@ -0,0 +1,86 @@ +# Chat Database Migration Guide + +This guide will help you migrate your chat database from the current server to a new PostgreSQL server. + +## Overview +Your chat system uses: +- Database: `rocketchat_converted` (PostgreSQL) +- Main tables: users, message, room, uploads, avatars, subscription +- File storage: db-convert/db/files/ directory with uploads and avatars +- Environment configuration for database connection + +## Migration Steps + +### 1. Pre-Migration Setup + +On your **new server**, ensure PostgreSQL is installed and running: +```bash +# Install PostgreSQL (if not already done) +sudo apt update +sudo apt install postgresql postgresql-contrib + +# Start PostgreSQL service +sudo systemctl start postgresql +sudo systemctl enable postgresql +``` + +### 2. Create Database Schema on New Server + +Run the provided migration script: +```bash +# On new server +sudo -u postgres psql -f create-new-database.sql +``` + +### 3. Export Data from Current Server + +Run the export script: +```bash +# On current server +./export-chat-data.sh +``` + +This will create: +- `chat-schema.sql` - Database schema +- `chat-data.sql` - All table data +- `chat-files.tar.gz` - All uploaded files and avatars + +### 4. Transfer Data to New Server + +```bash +# Copy files to new server +scp chat-schema.sql chat-data.sql chat-files.tar.gz user@new-server:/tmp/ +``` + +### 5. Import Data on New Server + +```bash +# On new server +./import-chat-data.sh +``` + +### 6. Update Configuration + +Update your environment variables to point to the new database server. + +### 7. Verify Migration + +Run the verification script to ensure everything transferred correctly: +```bash +node verify-migration.js +``` + +## Files Provided + +1. `create-new-database.sql` - Creates database and user on new server +2. `export-chat-data.sh` - Exports data from current server +3. `import-chat-data.sh` - Imports data to new server +4. `verify-migration.js` - Verifies data integrity +5. `update-config-template.env` - Template for new configuration + +## Important Notes + +- **Backup first**: Always backup your current database before migration +- **Downtime**: Plan for application downtime during migration +- **File permissions**: Ensure file permissions are preserved during transfer +- **Network access**: Ensure new server can accept connections from your application diff --git a/inventory-server/chat/package-lock.json b/inventory-server/chat/package-lock.json new file mode 100644 index 0000000..8d2ba57 --- /dev/null +++ b/inventory-server/chat/package-lock.json @@ -0,0 +1,1446 @@ +{ + "name": "chat-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chat-server", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "morgan": "^1.10.0", + "pg": "^8.11.0" + }, + "devDependencies": { + "nodemon": "^2.0.22" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "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/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": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.0", + "pg-pool": "^3.10.0", + "pg-protocol": "^1.10.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.5" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz", + "integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.0.tgz", + "integrity": "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz", + "integrity": "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz", + "integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "~7.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/inventory-server/chat/package.json b/inventory-server/chat/package.json new file mode 100644 index 0000000..34db9a0 --- /dev/null +++ b/inventory-server/chat/package.json @@ -0,0 +1,20 @@ +{ + "name": "chat-server", + "version": "1.0.0", + "description": "Chat archive server for Rocket.Chat data", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "pg": "^8.11.0", + "dotenv": "^16.0.3", + "morgan": "^1.10.0" + }, + "devDependencies": { + "nodemon": "^2.0.22" + } +} \ No newline at end of file diff --git a/inventory-server/chat/routes.js b/inventory-server/chat/routes.js new file mode 100644 index 0000000..9cbba9c --- /dev/null +++ b/inventory-server/chat/routes.js @@ -0,0 +1,649 @@ +const express = require('express'); +const path = require('path'); +const router = express.Router(); + +// Serve uploaded files with proper mapping from database paths to actual file locations +router.get('/files/uploads/*', async (req, res) => { + try { + // Extract the path from the URL (everything after /files/uploads/) + const requestPath = req.params[0]; + + // The URL path will be like: ufs/AmazonS3:Uploads/274Mf9CyHNG72oF86/filename.jpg + // We need to extract the mongo_id (274Mf9CyHNG72oF86) from this path + const pathParts = requestPath.split('/'); + let mongoId = null; + + // Find the mongo_id in the path structure + for (let i = 0; i < pathParts.length; i++) { + if (pathParts[i].includes('AmazonS3:Uploads') && i + 1 < pathParts.length) { + mongoId = pathParts[i + 1]; + break; + } + // Sometimes the mongo_id might be the last part of ufs/AmazonS3:Uploads/mongoId + if (pathParts[i] === 'AmazonS3:Uploads' && i + 1 < pathParts.length) { + mongoId = pathParts[i + 1]; + break; + } + } + + if (!mongoId) { + // Try to get mongo_id from database by matching the full path + const result = await global.pool.query(` + SELECT mongo_id, name, type + FROM uploads + WHERE path = $1 OR url = $1 + LIMIT 1 + `, [`/ufs/AmazonS3:Uploads/${requestPath}`, `/ufs/AmazonS3:Uploads/${requestPath}`]); + + if (result.rows.length > 0) { + mongoId = result.rows[0].mongo_id; + } + } + + if (!mongoId) { + return res.status(404).json({ error: 'File not found' }); + } + + // The actual file is stored with just the mongo_id as filename + const filePath = path.join(__dirname, 'db-convert/db/files/uploads', mongoId); + + // Get file info from database for proper content-type + const fileInfo = await global.pool.query(` + SELECT name, type + FROM uploads + WHERE mongo_id = $1 + LIMIT 1 + `, [mongoId]); + + if (fileInfo.rows.length === 0) { + return res.status(404).json({ error: 'File metadata not found' }); + } + + const { name, type } = fileInfo.rows[0]; + + // Set proper content type + if (type) { + res.set('Content-Type', type); + } + + // Set content disposition with original filename + if (name) { + res.set('Content-Disposition', `inline; filename="${name}"`); + } + + // Send the file + res.sendFile(filePath, (err) => { + if (err) { + console.error('Error serving file:', err); + if (!res.headersSent) { + res.status(404).json({ error: 'File not found on disk' }); + } + } + }); + + } catch (error) { + console.error('Error serving upload:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Also serve files directly by mongo_id for simpler access +router.get('/files/by-id/:mongoId', async (req, res) => { + try { + const { mongoId } = req.params; + + // Get file info from database + const fileInfo = await global.pool.query(` + SELECT name, type + FROM uploads + WHERE mongo_id = $1 + LIMIT 1 + `, [mongoId]); + + if (fileInfo.rows.length === 0) { + return res.status(404).json({ error: 'File not found' }); + } + + const { name, type } = fileInfo.rows[0]; + const filePath = path.join(__dirname, 'db-convert/db/files/uploads', mongoId); + + // Set proper content type and filename + if (type) { + res.set('Content-Type', type); + } + + if (name) { + res.set('Content-Disposition', `inline; filename="${name}"`); + } + + // Send the file + res.sendFile(filePath, (err) => { + if (err) { + console.error('Error serving file:', err); + if (!res.headersSent) { + res.status(404).json({ error: 'File not found on disk' }); + } + } + }); + + } catch (error) { + console.error('Error serving upload by ID:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Serve user avatars by mongo_id +router.get('/avatar/:mongoId', async (req, res) => { + try { + const { mongoId } = req.params; + + console.log(`[Avatar Debug] Looking up avatar for user mongo_id: ${mongoId}`); + + // First try to find avatar by user's avataretag + const userResult = await global.pool.query(` + SELECT avataretag, username FROM users WHERE mongo_id = $1 + `, [mongoId]); + + let avatarPath = null; + + if (userResult.rows.length > 0) { + const username = userResult.rows[0].username; + const avataretag = userResult.rows[0].avataretag; + + // Try method 1: Look up by avataretag -> etag (for users with avataretag set) + if (avataretag) { + console.log(`[Avatar Debug] Found user ${username} with avataretag: ${avataretag}`); + + const avatarResult = await global.pool.query(` + SELECT url, path FROM avatars WHERE etag = $1 + `, [avataretag]); + + if (avatarResult.rows.length > 0) { + const dbPath = avatarResult.rows[0].path || avatarResult.rows[0].url; + console.log(`[Avatar Debug] Found avatar record with path: ${dbPath}`); + + if (dbPath) { + const pathParts = dbPath.split('/'); + for (let i = 0; i < pathParts.length; i++) { + if (pathParts[i].includes('AmazonS3:Avatars') && i + 1 < pathParts.length) { + const avatarMongoId = pathParts[i + 1]; + avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', avatarMongoId); + console.log(`[Avatar Debug] Extracted avatar mongo_id: ${avatarMongoId}, full path: ${avatarPath}`); + break; + } + } + } + } else { + console.log(`[Avatar Debug] No avatar record found for etag: ${avataretag}`); + } + } + + // Try method 2: Look up by userid directly (for users without avataretag) + if (!avatarPath) { + console.log(`[Avatar Debug] Trying direct userid lookup for user ${username} (${mongoId})`); + + const avatarResult = await global.pool.query(` + SELECT url, path FROM avatars WHERE userid = $1 + `, [mongoId]); + + if (avatarResult.rows.length > 0) { + const dbPath = avatarResult.rows[0].path || avatarResult.rows[0].url; + console.log(`[Avatar Debug] Found avatar record by userid with path: ${dbPath}`); + + if (dbPath) { + const pathParts = dbPath.split('/'); + for (let i = 0; i < pathParts.length; i++) { + if (pathParts[i].includes('AmazonS3:Avatars') && i + 1 < pathParts.length) { + const avatarMongoId = pathParts[i + 1]; + avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', avatarMongoId); + console.log(`[Avatar Debug] Extracted avatar mongo_id: ${avatarMongoId}, full path: ${avatarPath}`); + break; + } + } + } + } else { + console.log(`[Avatar Debug] No avatar record found for userid: ${mongoId}`); + } + } + } else { + console.log(`[Avatar Debug] No user found for mongo_id: ${mongoId}`); + } + + // Fallback: try direct lookup by user mongo_id + if (!avatarPath) { + avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', mongoId); + console.log(`[Avatar Debug] Using fallback path: ${avatarPath}`); + } + + // Set proper content type for images + res.set('Content-Type', 'image/jpeg'); // Most avatars are likely JPEG + + // Send the file + res.sendFile(avatarPath, (err) => { + if (err) { + // If avatar doesn't exist, send a default 404 or generate initials + console.log(`[Avatar Debug] Avatar file not found at path: ${avatarPath}, error:`, err.message); + if (!res.headersSent) { + res.status(404).json({ error: 'Avatar not found' }); + } + } else { + console.log(`[Avatar Debug] Successfully served avatar from: ${avatarPath}`); + } + }); + + } catch (error) { + console.error('Error serving avatar:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Serve avatars statically as fallback +router.use('/files/avatars', express.static(path.join(__dirname, 'db-convert/db/files/avatars'))); + +// Get all users for the "view as" dropdown (active and inactive) +router.get('/users', async (req, res) => { + try { + const result = await global.pool.query(` + SELECT id, username, name, type, active, status, lastlogin, + statustext, utcoffset, statusconnection, mongo_id, avataretag + FROM users + WHERE type = 'user' + ORDER BY + active DESC, -- Active users first + CASE + WHEN status = 'online' THEN 1 + WHEN status = 'away' THEN 2 + WHEN status = 'busy' THEN 3 + ELSE 4 + END, + name ASC + `); + + res.json({ + status: 'success', + users: result.rows + }); + } catch (error) { + console.error('Error fetching users:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to fetch users', + details: error.message + }); + } +}); + +// Get rooms for a specific user with enhanced room names for direct messages +router.get('/users/:userId/rooms', async (req, res) => { + const { userId } = req.params; + + try { + // Get the current user's mongo_id for filtering + const userResult = await global.pool.query(` + SELECT mongo_id, username FROM users WHERE id = $1 + `, [userId]); + + if (userResult.rows.length === 0) { + return res.status(404).json({ + status: 'error', + error: 'User not found' + }); + } + + const currentUserMongoId = userResult.rows[0].mongo_id; + const currentUsername = userResult.rows[0].username; + + // Get rooms where the user is a member with proper naming from subscription table + // Include archived and closed rooms but sort them at the bottom + const result = await global.pool.query(` + SELECT DISTINCT + r.id, + r.mongo_id as room_mongo_id, + r.name, + r.fname, + r.t as type, + r.msgs, + r.lm as last_message_date, + r.usernames, + r.uids, + r.userscount, + r.description, + r.teamid, + r.archived, + s.open, + -- Use the subscription's name for direct messages (excludes current user) + -- For channels/groups, use room's fname or name + CASE + WHEN r.t = 'd' THEN COALESCE(s.fname, s.name, 'Unknown User') + ELSE COALESCE(r.fname, r.name, 'Unnamed Room') + END as display_name + FROM room r + JOIN subscription s ON s.rid = r.mongo_id + WHERE s.u->>'_id' = $1 + ORDER BY + s.open DESC NULLS LAST, -- Open rooms first + r.archived NULLS FIRST, -- Non-archived first (nulls treated as false) + r.lm DESC NULLS LAST + LIMIT 50 + `, [currentUserMongoId]); + + // Enhance rooms with participant information for direct messages + const enhancedRooms = await Promise.all(result.rows.map(async (room) => { + if (room.type === 'd' && room.uids) { + // Get participant info (excluding current user) for direct messages + const participantResult = await global.pool.query(` + SELECT u.username, u.name, u.mongo_id, u.avataretag + FROM users u + WHERE u.mongo_id = ANY($1::text[]) + AND u.mongo_id != $2 + `, [room.uids, currentUserMongoId]); + + room.participants = participantResult.rows; + } + return room; + })); + + res.json({ + status: 'success', + rooms: enhancedRooms + }); + } catch (error) { + console.error('Error fetching user rooms:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to fetch user rooms', + details: error.message + }); + } +}); + +// Get room details including participants +router.get('/rooms/:roomId', async (req, res) => { + const { roomId } = req.params; + const { userId } = req.query; // Accept current user ID as query parameter + + try { + const result = await global.pool.query(` + SELECT r.id, r.name, r.fname, r.t as type, r.msgs, r.description, + r.lm as last_message_date, r.usernames, r.uids, r.userscount, r.teamid + FROM room r + WHERE r.id = $1 + `, [roomId]); + + if (result.rows.length === 0) { + return res.status(404).json({ + status: 'error', + error: 'Room not found' + }); + } + + const room = result.rows[0]; + + // For direct messages, get the proper display name based on current user + if (room.type === 'd' && room.uids && userId) { + // Get current user's mongo_id + const userResult = await global.pool.query(` + SELECT mongo_id FROM users WHERE id = $1 + `, [userId]); + + if (userResult.rows.length > 0) { + const currentUserMongoId = userResult.rows[0].mongo_id; + + // Get display name from subscription table for this user + // Use room mongo_id to match with subscription.rid + const roomMongoResult = await global.pool.query(` + SELECT mongo_id FROM room WHERE id = $1 + `, [roomId]); + + if (roomMongoResult.rows.length > 0) { + const roomMongoId = roomMongoResult.rows[0].mongo_id; + + const subscriptionResult = await global.pool.query(` + SELECT fname, name FROM subscription + WHERE rid = $1 AND u->>'_id' = $2 + `, [roomMongoId, currentUserMongoId]); + + if (subscriptionResult.rows.length > 0) { + const sub = subscriptionResult.rows[0]; + room.display_name = sub.fname || sub.name || 'Unknown User'; + } + } + } + + // Get all participants for additional info + const participantResult = await global.pool.query(` + SELECT username, name + FROM users + WHERE mongo_id = ANY($1::text[]) + `, [room.uids]); + + room.participants = participantResult.rows; + } else { + // For channels/groups, use room's fname or name + room.display_name = room.fname || room.name || 'Unnamed Room'; + } + + res.json({ + status: 'success', + room: room + }); + } catch (error) { + console.error('Error fetching room details:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to fetch room details', + details: error.message + }); + } +}); + +// Get messages for a specific room (fast, without attachments) +router.get('/rooms/:roomId/messages', async (req, res) => { + const { roomId } = req.params; + const { limit = 50, offset = 0, before } = req.query; + + try { + // Fast query - just get messages without expensive attachment joins + let query = ` + SELECT m.id, m.msg, m.ts, m.u, m._updatedat, m.urls, m.mentions, m.md + FROM message m + JOIN room r ON m.rid = r.mongo_id + WHERE r.id = $1 + `; + + const params = [roomId]; + + if (before) { + query += ` AND m.ts < $${params.length + 1}`; + params.push(before); + } + + query += ` ORDER BY m.ts DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; + params.push(limit, offset); + + const result = await global.pool.query(query, params); + + // Add empty attachments array for now - attachments will be loaded separately if needed + const messages = result.rows.map(msg => ({ + ...msg, + attachments: [] + })); + + res.json({ + status: 'success', + messages: messages.reverse() // Reverse to show oldest first + }); + } catch (error) { + console.error('Error fetching messages:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to fetch messages', + details: error.message + }); + } +}); + +// Get attachments for specific messages (called separately for performance) +router.post('/messages/attachments', async (req, res) => { + const { messageIds } = req.body; + + if (!messageIds || !Array.isArray(messageIds) || messageIds.length === 0) { + return res.json({ status: 'success', attachments: {} }); + } + + try { + // Get room mongo_id from first message to limit search scope + const roomQuery = await global.pool.query(` + SELECT r.mongo_id as room_mongo_id + FROM message m + JOIN room r ON m.rid = r.mongo_id + WHERE m.id = $1 + LIMIT 1 + `, [messageIds[0]]); + + if (roomQuery.rows.length === 0) { + return res.json({ status: 'success', attachments: {} }); + } + + const roomMongoId = roomQuery.rows[0].room_mongo_id; + + // Get messages and their upload timestamps + const messagesQuery = await global.pool.query(` + SELECT m.id, m.ts, m.u->>'_id' as user_id + FROM message m + WHERE m.id = ANY($1::int[]) + `, [messageIds]); + + if (messagesQuery.rows.length === 0) { + return res.json({ status: 'success', attachments: {} }); + } + + // Build a map of user_id -> array of message timestamps for efficient lookup + const userTimeMap = {}; + const messageMap = {}; + messagesQuery.rows.forEach(msg => { + if (!userTimeMap[msg.user_id]) { + userTimeMap[msg.user_id] = []; + } + userTimeMap[msg.user_id].push(msg.ts); + messageMap[msg.id] = { ts: msg.ts, user_id: msg.user_id }; + }); + + // Get attachments for this room and these users + const uploadsQuery = await global.pool.query(` + SELECT mongo_id, name, size, type, url, path, typegroup, identify, + userid, uploadedat + FROM uploads + WHERE rid = $1 + AND userid = ANY($2::text[]) + ORDER BY uploadedat + `, [roomMongoId, Object.keys(userTimeMap)]); + + // Match attachments to messages based on timestamp proximity (within 5 minutes) + const attachmentsByMessage = {}; + + uploadsQuery.rows.forEach(upload => { + const uploadTime = new Date(upload.uploadedat).getTime(); + + // Find the closest message from this user within 5 minutes + let closestMessageId = null; + let closestTimeDiff = Infinity; + + Object.entries(messageMap).forEach(([msgId, msgData]) => { + if (msgData.user_id === upload.userid) { + const msgTime = new Date(msgData.ts).getTime(); + const timeDiff = Math.abs(uploadTime - msgTime); + + if (timeDiff < 300000 && timeDiff < closestTimeDiff) { // 5 minutes = 300000ms + closestMessageId = msgId; + closestTimeDiff = timeDiff; + } + } + }); + + if (closestMessageId) { + if (!attachmentsByMessage[closestMessageId]) { + attachmentsByMessage[closestMessageId] = []; + } + + attachmentsByMessage[closestMessageId].push({ + id: upload.id, + mongo_id: upload.mongo_id, + name: upload.name, + size: upload.size, + type: upload.type, + url: upload.url, + path: upload.path, + typegroup: upload.typegroup, + identify: upload.identify + }); + } + }); + + res.json({ + status: 'success', + attachments: attachmentsByMessage + }); + + } catch (error) { + console.error('Error fetching message attachments:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to fetch attachments', + details: error.message + }); + } +}); + +// Search messages in accessible rooms for a user +router.get('/users/:userId/search', async (req, res) => { + const { userId } = req.params; + const { q, limit = 20 } = req.query; + + if (!q || q.length < 2) { + return res.status(400).json({ + status: 'error', + error: 'Search query must be at least 2 characters' + }); + } + + try { + const userResult = await global.pool.query(` + SELECT mongo_id FROM users WHERE id = $1 + `, [userId]); + + if (userResult.rows.length === 0) { + return res.status(404).json({ + status: 'error', + error: 'User not found' + }); + } + + const currentUserMongoId = userResult.rows[0].mongo_id; + + const result = await global.pool.query(` + SELECT m.id, m.msg, m.ts, m.u, r.id as room_id, r.name as room_name, r.fname as room_fname, r.t as room_type + FROM message m + JOIN room r ON m.rid = r.mongo_id + JOIN subscription s ON s.rid = r.mongo_id AND s.u->>'_id' = $1 + WHERE m.msg ILIKE $2 + AND r.archived IS NOT TRUE + ORDER BY m.ts DESC + LIMIT $3 + `, [currentUserMongoId, `%${q}%`, limit]); + + res.json({ + status: 'success', + results: result.rows + }); + } catch (error) { + console.error('Error searching messages:', error); + res.status(500).json({ + status: 'error', + error: 'Failed to search messages', + details: error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/chat/server.js b/inventory-server/chat/server.js new file mode 100644 index 0000000..5c90e56 --- /dev/null +++ b/inventory-server/chat/server.js @@ -0,0 +1,83 @@ +require('dotenv').config({ path: '../.env' }); +const express = require('express'); +const cors = require('cors'); +const { Pool } = require('pg'); +const morgan = require('morgan'); +const chatRoutes = require('./routes'); + +// Log startup configuration +console.log('Starting chat server with config:', { + host: process.env.CHAT_DB_HOST, + user: process.env.CHAT_DB_USER, + database: process.env.CHAT_DB_NAME || 'rocketchat_converted', + port: process.env.CHAT_DB_PORT, + chat_port: process.env.CHAT_PORT || 3014 +}); + +const app = express(); +const port = process.env.CHAT_PORT || 3014; + +// Database configuration for rocketchat_converted database +const pool = new Pool({ + host: process.env.CHAT_DB_HOST, + user: process.env.CHAT_DB_USER, + password: process.env.CHAT_DB_PASSWORD, + database: process.env.CHAT_DB_NAME || 'rocketchat_converted', + port: process.env.CHAT_DB_PORT, +}); + +// Make pool available globally +global.pool = pool; + +// Middleware +app.use(express.json()); +app.use(morgan('combined')); +app.use(cors({ + origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site'], + credentials: true +})); + +// Test database connection endpoint +app.get('/test-db', async (req, res) => { + try { + const result = await pool.query('SELECT COUNT(*) as user_count FROM users WHERE active = true'); + const messageResult = await pool.query('SELECT COUNT(*) as message_count FROM message'); + const roomResult = await pool.query('SELECT COUNT(*) as room_count FROM room'); + + res.json({ + status: 'success', + database: 'rocketchat_converted', + stats: { + active_users: parseInt(result.rows[0].user_count), + total_messages: parseInt(messageResult.rows[0].message_count), + total_rooms: parseInt(roomResult.rows[0].room_count) + } + }); + } catch (error) { + console.error('Database test error:', error); + res.status(500).json({ + status: 'error', + error: 'Database connection failed', + details: error.message + }); + } +}); + +// Mount all routes from routes.js +app.use('/', chatRoutes); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'healthy' }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Something broke!' }); +}); + +// Start server +app.listen(port, () => { + console.log(`Chat server running on port ${port}`); +}); \ No newline at end of file diff --git a/inventory-server/chat/update-config-template.env b/inventory-server/chat/update-config-template.env new file mode 100644 index 0000000..0a34d84 --- /dev/null +++ b/inventory-server/chat/update-config-template.env @@ -0,0 +1,26 @@ +# Chat Server Database Configuration Template +# Copy this to your .env file and update the values for your new server + +# Database Configuration for New Server +CHAT_DB_HOST=your-new-server-ip-or-hostname +CHAT_DB_PORT=5432 +CHAT_DB_NAME=rocketchat_converted +CHAT_DB_USER=rocketchat_user +CHAT_DB_PASSWORD=your-secure-password + +# Chat Server Port +CHAT_PORT=3014 + +# Example configuration: +# CHAT_DB_HOST=192.168.1.100 +# CHAT_DB_PORT=5432 +# CHAT_DB_NAME=rocketchat_converted +# CHAT_DB_USER=rocketchat_user +# CHAT_DB_PASSWORD=MySecureP@ssw0rd123 + +# Notes: +# - Replace 'your-new-server-ip-or-hostname' with actual server address +# - Use a strong password for CHAT_DB_PASSWORD +# - Ensure the new server allows connections from your application server +# - Update any firewall rules to allow PostgreSQL connections (port 5432) +# - Test connectivity before updating production configuration diff --git a/inventory-server/chat/verify-migration.js b/inventory-server/chat/verify-migration.js new file mode 100755 index 0000000..80fb178 --- /dev/null +++ b/inventory-server/chat/verify-migration.js @@ -0,0 +1,231 @@ +#!/usr/bin/env node + +/** + * Chat Database Migration Verification Script + * + * This script verifies that the chat database migration was successful + * by comparing record counts and testing basic functionality. + */ + +require('dotenv').config({ path: '../.env' }); +const { Pool } = require('pg'); + +// Database configuration +const pool = new Pool({ + host: process.env.CHAT_DB_HOST || 'localhost', + user: process.env.CHAT_DB_USER || 'rocketchat_user', + password: process.env.CHAT_DB_PASSWORD, + database: process.env.CHAT_DB_NAME || 'rocketchat_converted', + port: process.env.CHAT_DB_PORT || 5432, +}); + +const originalStats = process.argv[2] ? JSON.parse(process.argv[2]) : null; + +async function verifyMigration() { + console.log('🔍 Starting migration verification...\n'); + + try { + // Test basic connection + console.log('🔗 Testing database connection...'); + const versionResult = await pool.query('SELECT version()'); + console.log('✅ Database connection successful'); + console.log(` PostgreSQL version: ${versionResult.rows[0].version.split(' ')[1]}\n`); + + // Get table statistics + console.log('📊 Checking table statistics...'); + const statsResult = await pool.query(` + SELECT + tablename, + n_live_tup as row_count, + n_dead_tup as dead_rows, + schemaname + FROM pg_stat_user_tables + WHERE schemaname = 'public' + ORDER BY n_live_tup DESC + `); + + if (statsResult.rows.length === 0) { + console.log('❌ No tables found! Migration may have failed.'); + return false; + } + + console.log('📋 Table Statistics:'); + console.log(' Table Name | Row Count | Dead Rows'); + console.log(' -------------------|-----------|----------'); + + let totalRows = 0; + const tableStats = {}; + + for (const row of statsResult.rows) { + const rowCount = parseInt(row.row_count) || 0; + const deadRows = parseInt(row.dead_rows) || 0; + totalRows += rowCount; + tableStats[row.tablename] = rowCount; + + console.log(` ${row.tablename.padEnd(18)} | ${rowCount.toString().padStart(9)} | ${deadRows.toString().padStart(8)}`); + } + + console.log(`\n Total rows across all tables: ${totalRows}\n`); + + // Verify critical tables exist and have data + const criticalTables = ['users', 'message', 'room']; + console.log('🔑 Checking critical tables...'); + + for (const table of criticalTables) { + if (tableStats[table] > 0) { + console.log(`✅ ${table}: ${tableStats[table]} rows`); + } else if (tableStats[table] === 0) { + console.log(`⚠️ ${table}: table exists but is empty`); + } else { + console.log(`❌ ${table}: table not found`); + return false; + } + } + + // Test specific functionality + console.log('\n🧪 Testing specific functionality...'); + + // Test users table + const userTest = await pool.query(` + SELECT COUNT(*) as total_users, + COUNT(*) FILTER (WHERE active = true) as active_users, + COUNT(*) FILTER (WHERE type = 'user') as regular_users + FROM users + `); + + if (userTest.rows[0]) { + const { total_users, active_users, regular_users } = userTest.rows[0]; + console.log(`✅ Users: ${total_users} total, ${active_users} active, ${regular_users} regular users`); + } + + // Test messages table + const messageTest = await pool.query(` + SELECT COUNT(*) as total_messages, + COUNT(DISTINCT rid) as unique_rooms, + MIN(ts) as oldest_message, + MAX(ts) as newest_message + FROM message + `); + + if (messageTest.rows[0]) { + const { total_messages, unique_rooms, oldest_message, newest_message } = messageTest.rows[0]; + console.log(`✅ Messages: ${total_messages} total across ${unique_rooms} rooms`); + if (oldest_message && newest_message) { + console.log(` Date range: ${oldest_message.toISOString().split('T')[0]} to ${newest_message.toISOString().split('T')[0]}`); + } + } + + // Test rooms table + const roomTest = await pool.query(` + SELECT COUNT(*) as total_rooms, + COUNT(*) FILTER (WHERE t = 'c') as channels, + COUNT(*) FILTER (WHERE t = 'p') as private_groups, + COUNT(*) FILTER (WHERE t = 'd') as direct_messages + FROM room + `); + + if (roomTest.rows[0]) { + const { total_rooms, channels, private_groups, direct_messages } = roomTest.rows[0]; + console.log(`✅ Rooms: ${total_rooms} total (${channels} channels, ${private_groups} private, ${direct_messages} DMs)`); + } + + // Test file uploads if table exists + if (tableStats.uploads > 0) { + const uploadTest = await pool.query(` + SELECT COUNT(*) as total_uploads, + COUNT(DISTINCT typegroup) as file_types, + pg_size_pretty(SUM(size)) as total_size + FROM uploads + WHERE size IS NOT NULL + `); + + if (uploadTest.rows[0]) { + const { total_uploads, file_types, total_size } = uploadTest.rows[0]; + console.log(`✅ Uploads: ${total_uploads} files, ${file_types} types, ${total_size || 'unknown size'}`); + } + } + + // Test server health endpoint simulation + console.log('\n🏥 Testing application endpoints simulation...'); + + try { + const healthTest = await pool.query(` + SELECT + (SELECT COUNT(*) FROM users WHERE active = true) as active_users, + (SELECT COUNT(*) FROM message) as total_messages, + (SELECT COUNT(*) FROM room) as total_rooms + `); + + if (healthTest.rows[0]) { + const stats = healthTest.rows[0]; + console.log('✅ Health check simulation passed'); + console.log(` Active users: ${stats.active_users}`); + console.log(` Total messages: ${stats.total_messages}`); + console.log(` Total rooms: ${stats.total_rooms}`); + } + } catch (error) { + console.log(`⚠️ Health check simulation failed: ${error.message}`); + } + + // Check indexes + console.log('\n📇 Checking database indexes...'); + const indexResult = await pool.query(` + SELECT + schemaname, + tablename, + indexname, + indexdef + FROM pg_indexes + WHERE schemaname = 'public' + ORDER BY tablename, indexname + `); + + const indexesByTable = {}; + for (const idx of indexResult.rows) { + if (!indexesByTable[idx.tablename]) { + indexesByTable[idx.tablename] = []; + } + indexesByTable[idx.tablename].push(idx.indexname); + } + + for (const [table, indexes] of Object.entries(indexesByTable)) { + console.log(` ${table}: ${indexes.length} indexes`); + } + + console.log('\n🎉 Migration verification completed successfully!'); + console.log('\n✅ Summary:'); + console.log(` - Database connection: Working`); + console.log(` - Tables created: ${statsResult.rows.length}`); + console.log(` - Total data rows: ${totalRows}`); + console.log(` - Critical tables: All present`); + console.log(` - Indexes: ${indexResult.rows.length} total`); + + console.log('\n🚀 Next steps:'); + console.log(' 1. Update your application configuration'); + console.log(' 2. Start your chat server'); + console.log(' 3. Test chat functionality in the browser'); + console.log(' 4. Monitor logs for any issues'); + + return true; + + } catch (error) { + console.error('❌ Migration verification failed:', error.message); + console.error('\n🔧 Troubleshooting steps:'); + console.error(' 1. Check database connection settings'); + console.error(' 2. Verify database and user exist'); + console.error(' 3. Check PostgreSQL logs'); + console.error(' 4. Ensure import completed without errors'); + return false; + } finally { + await pool.end(); + } +} + +// Run verification +if (require.main === module) { + verifyMigration().then(success => { + process.exit(success ? 0 : 1); + }); +} + +module.exports = { verifyMigration }; diff --git a/inventory-server/dashboard/.env-future b/inventory-server/dashboard/.env-future new file mode 100644 index 0000000..3902b59 --- /dev/null +++ b/inventory-server/dashboard/.env-future @@ -0,0 +1,20 @@ +# Caching Server Configuration +PORT=3010 +NODE_ENV=production + +# Database Configuration +MONGODB_URI=mongodb://dashboard_user:WDRFWiGXEeaC6aAyUKuT@localhost:27017/dashboard?authSource=dashboard +REDIS_URL=redis://:Wgj32YXxxVLtPZoVzUnP@localhost:6379 + +# Gorgias +GORGIAS_API_USERNAME=matt@acherryontop.com +GORGIAS_API_PASSWORD=d2ed0d23d2a7bf11a633a12fb260769f4e4a970d440693e7d64b8d2223fa6503 + +# GA4 credentials +GA_PROPERTY_ID=281045851 +GOOGLE_APPLICATION_CREDENTIALS_JSON={"type": "service_account","project_id": "acot-stats","private_key_id": "259d1fd9864efbfa38b8ba02fdd74dc008ace3c5","private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5Y6foai8WF98k\nIA0yLn94Y3lmDYlyvI9xL2YqSZSyvgK35wdWRTIaEvHKdiUWuYi3ZPdkYmz1OYiV\njVfR2g+mFpA7MI/JMwyGWwjnV4WW2q6INfgi/PvHlbP3LyyQo0B8CvAY0CHqrpDs\nlJQhAkqmteU24dqcdZoV3vM8JMsDiXm44DqwXsEfWibKv4i0mWNkwiEQr0yImHwb\nbjgclwVLLi5kdM2+49PXr47LCODdL+xmX0uSdgSG6XYqEIVsEOXIUJKzqUe036b/\nEFQ0BxWdJBWs/MYOapn/NNv+Mts+am2ipUuIcgPbOut4xa2Fkky93WnJf0tB+VJP\njFnyZJhdAgMBAAECggEAC980Cp/4zvSNZMNWr6l8ST8u2thavnRmcoGYtx7ffQjK\nT3Dl2TefgJLzqpr2lLt3OVint7p5LsUAmE8lBLpu+RxbH9HkIKbPvQTfD5gyZQQx\nBruqCGzkn2st9fzZNj6gwQYe9P/TGYkUnR8wqI0nLwDZTQful3QNKixiWC4lAAoK\nqdd6H++pqjVUiTqgFwFD3zBAhO0Lp8m/c5vTRT5kxi0wCTK66FaaGLr2OwZHcohp\nE8rEcTZ5kaJzBwqEz522R6ufQqN1Swoq4K6Ul3aAc59539VdrLNs++/eRH38MMVq\n5UTwBrH+zIkXIYv4mtGpR1NWGO2bZ652GzGXNEXcQQKBgQD9WsMmioIeWR9P9I0r\nIY+yyxz1EyscutUtnOtROT36OxokrzQaAKDz/OC3jVnhZSkzG6RcmmK/AJrcU+2m\n1L4mZGfF3DdeTqtK/KkNzGs9yRPDkbb/MF0wgtcvfE8tJH/suiDJKQNsjeaQIQW3\n4NvDxs0w60m9r9tk1CQau94ovQKBgQC7UzeA0mDSxIB5agGbvnzaJJTvAFvnCvhz\nu3ZakTlNecAHu4eOMc0+OCHFPLJlLL4b0oraOxZIszX9BTlgcstBmTUk03TibNsS\nsDiImHFC4hE5x6EPdifnkVFUXPMZ/eF0mHUPBEn41ipw1hoLfl6W+aYW9QUxBMWA\nzdMH4rg4IQKBgQCFcMaUiCNchKhfXnj0HKspCp3n3v64FReu/JVcpH+mSnbMl5Mj\nlu0vVSOuyb5rXvLCPm7lb1NPMqxeG75yPl8grYWSyxhGjbzetBD+eYqKclv8h8UQ\nx5JtuJxKIHk7V5whPS+DhByPknW7uAjg/ogBp7XvbB3c0MEHbEzP3991KQKBgC+a\n610Kmd6WX4v7e6Mn2rTZXRwL/E8QA6nttxs3Etf0m++bIczqLR2lyDdGwJNjtoB9\nlhn1sCkTmiHOBRHUuoDWPaI5NtggD+CE9ikIjKgRqY0EhZLXVTbNQFzvLjypv3UR\nFZaWYXIigzCfyIipOcKmeSYWaJZXfxXHuNylKmnhAoGAFa84AuOOGUr+pEvtUzIr\nvBKu1mnQbbsLEhgf3Tw88K3sO5OlguAwBEvD4eitj/aU5u2vJJhFa67cuERLsZru\n0sjtQwP6CJbWF4uaH0Hso4KQvnwl4BfdKwUncqoKtHrQiuGMvr5P5G941+Ax8brE\nJlC2e/RPUQKxScpK3nNK9mc=\n-----END PRIVATE KEY-----\n","client_email": "matt-dashboard@acot-stats.iam.gserviceaccount.com","client_id": "106112731322970982546","auth_uri": "https://accounts.google.com/o/oauth2/auth","token_uri": "https://oauth2.googleapis.com/token","auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/matt-dashboard%40acot-stats.iam.gserviceaccount.com","universe_domain": "googleapis.com"} + +# Logging +LOG_LEVEL=info +LOG_MAX_SIZE=10m +LOG_MAX_FILES=5 \ No newline at end of file diff --git a/inventory-server/dashboard/acot-server/README.md b/inventory-server/dashboard/acot-server/README.md new file mode 100644 index 0000000..5cf12d7 --- /dev/null +++ b/inventory-server/dashboard/acot-server/README.md @@ -0,0 +1,205 @@ +# ACOT Server + +This server replaces the Klaviyo integration with direct database queries to the production MySQL database via SSH tunnel. It provides seamless API compatibility for all frontend components without requiring any frontend changes. + +## Setup + +1. **Environment Variables**: Copy `.env.example` to `.env` and configure: + ``` + DB_HOST=localhost + DB_PORT=3306 + DB_USER=your_db_user + DB_PASSWORD=your_db_password + DB_NAME=your_db_name + PORT=3007 + NODE_ENV=development + ``` + +2. **SSH Tunnel**: Ensure your SSH tunnel to the production database is running on localhost:3306. + +3. **Install Dependencies**: + ```bash + npm install + ``` + +4. **Start Server**: + ```bash + npm start + ``` + +## API Endpoints + +All endpoints provide exact API compatibility with the previous Klaviyo implementation: + +### Main Statistics +- `GET /api/acot/events/stats` - Complete statistics dashboard data + - Query params: `timeRange` (today, yesterday, thisWeek, lastWeek, thisMonth, lastMonth, last7days, last30days, last90days) or `startDate`/`endDate` for custom ranges + - Returns: Revenue, orders, AOV, shipping data, order types, brands/categories, refunds, cancellations, best day, peak hour, order ranges, period progress, projections + +### Daily Details +- `GET /api/acot/events/stats/details` - Daily breakdown with previous period comparisons + - Query params: `timeRange`, `metric` (revenue, orders, average_order, etc.), `daily=true` + - Returns: Array of daily data points with trend comparisons + +### Products +- `GET /api/acot/events/products` - Top products with sales data + - Query params: `timeRange` + - Returns: Product list with images, sales quantities, revenue, and order counts + +### Projections +- `GET /api/acot/events/projection` - Smart revenue projections for incomplete periods + - Query params: `timeRange` + - Returns: Projected revenue with confidence levels based on historical patterns + +### Health Check +- `GET /api/acot/test` - Server health and database connectivity test + +## Database Schema + +The server queries the following main tables: + +### Orders (`_order`) +- **Key fields**: `order_id`, `date_placed`, `summary_total`, `order_status`, `ship_method_selected`, `stats_waiting_preorder` +- **Valid orders**: `order_status > 15` +- **Cancelled orders**: `order_status = 15` +- **Shipped orders**: `order_status IN (100, 92)` +- **Pre-orders**: `stats_waiting_preorder > 0` +- **Local pickup**: `ship_method_selected = 'localpickup'` +- **On-hold orders**: `ship_method_selected = 'holdit'` + +### Order Items (`order_items`) +- **Fields**: `order_id`, `prod_pid`, `qty_ordered`, `prod_price` +- **Purpose**: Links orders to products for detailed analysis + +### Products (`products`) +- **Fields**: `pid`, `description` (product name), `company` +- **Purpose**: Product information and brand data + +### Product Images (`product_images`) +- **Fields**: `pid`, `iid`, `order` (priority) +- **Primary image**: `order = 255` (highest priority) +- **Image URL generation**: `https://sbing.com/i/products/0000/{prefix}/{pid}-{type}-{iid}.jpg` + +### Payments (`order_payment`) +- **Refunds**: `payment_amount < 0` +- **Purpose**: Track refund amounts and counts + +## Business Logic + +### Time Handling +- **Timezone**: All calculations in UTC-5 (Eastern Time) +- **Business Day**: 1 AM - 12:59 AM Eastern (25-hour business day) +- **Format**: MySQL DATETIME format (YYYY-MM-DD HH:MM:SS) +- **Period Boundaries**: Calculated using `timeUtils.js` for consistent time range handling + +### Order Processing +- **Revenue Calculation**: Only includes orders with `order_status > 15` +- **Order Types**: + - Pre-orders: `stats_waiting_preorder > 0` + - Local pickup: `ship_method_selected = 'localpickup'` + - On-hold: `ship_method_selected = 'holdit'` +- **Shipping Methods**: Mapped to friendly names (e.g., `usps_ground_advantage` → "USPS Ground Advantage") + +### Projections +- **Period Progress**: Calculated based on current time within the selected period +- **Simple Projection**: Linear extrapolation based on current progress +- **Smart Projection**: Uses historical data patterns for more accurate forecasting +- **Confidence Levels**: Based on data consistency and historical accuracy + +### Image URL Generation +- **Pattern**: `https://sbing.com/i/products/0000/{prefix}/{pid}-{type}-{iid}.jpg` +- **Prefix**: First 2 digits of product ID +- **Type**: "main" for primary images +- **Fallback**: Uses primary image (order=255) when available + +## Frontend Integration + +### Service Layer (`services/acotService.js`) +- **Purpose**: Replaces direct Klaviyo API calls with acot-server calls +- **Methods**: `getStats()`, `getStatsDetails()`, `getProducts()`, `getProjection()` +- **Logging**: Axios interceptors for request/response logging +- **Environment**: Automatic URL handling (proxy in dev, direct in production) + +### Component Updates +All 5 main components updated to use `acotService`: +- **StatCards.jsx**: Main dashboard statistics +- **MiniStatCards.jsx**: Compact statistics view +- **SalesChart.jsx**: Revenue and order trends +- **MiniSalesChart.jsx**: Compact chart view +- **ProductGrid.jsx**: Top products table + +### Proxy Configuration (`vite.config.js`) +```javascript +'/api/acot': { + target: 'http://localhost:3007', + changeOrigin: true, + secure: false +} +``` + +## Key Features + +### Complete Business Intelligence +- **Revenue Analytics**: Total revenue, trends, projections +- **Order Analysis**: Counts, types, status tracking +- **Product Performance**: Top sellers, revenue contribution +- **Shipping Intelligence**: Methods, locations, distribution +- **Customer Insights**: Order value ranges, patterns +- **Operational Metrics**: Refunds, cancellations, peak hours + +### Performance Optimizations +- **Connection Pooling**: Efficient database connection management +- **Query Optimization**: Indexed queries with proper WHERE clauses +- **Caching Strategy**: Frontend caching for detail views +- **Batch Processing**: Efficient data aggregation + +### Error Handling +- **Database Connectivity**: Graceful handling of connection issues +- **Query Failures**: Detailed error logging and user-friendly messages +- **Data Validation**: Input sanitization and validation +- **Fallback Mechanisms**: Default values for missing data + +## Simplified Elements + +Due to database complexity, some features are simplified: +- **Brands**: Shows "Various Brands" (companies table structure complex) +- **Categories**: Shows "General" (category relationships complex) + +These can be enhanced in future iterations with proper category mapping. + +## Testing + +Test the server functionality: + +```bash +# Health check +curl http://localhost:3007/api/acot/test + +# Today's stats +curl http://localhost:3007/api/acot/events/stats?timeRange=today + +# Last 30 days with details +curl http://localhost:3007/api/acot/events/stats/details?timeRange=last30days&daily=true + +# Top products +curl http://localhost:3007/api/acot/events/products?timeRange=thisWeek + +# Revenue projection +curl http://localhost:3007/api/acot/events/projection?timeRange=today +``` + +## Development Notes + +- **No Frontend Changes**: Complete drop-in replacement for Klaviyo +- **API Compatibility**: Maintains exact response structure +- **Business Logic**: Implements all complex e-commerce calculations +- **Scalability**: Designed for production workloads +- **Maintainability**: Well-documented code with clear separation of concerns + +## Future Enhancements + +- Enhanced category and brand mapping +- Real-time notifications for significant events +- Advanced analytics and forecasting +- Customer segmentation analysis +- Inventory integration \ No newline at end of file diff --git a/inventory-server/dashboard/acot-server/db/connection.js b/inventory-server/dashboard/acot-server/db/connection.js new file mode 100644 index 0000000..a7a47d0 --- /dev/null +++ b/inventory-server/dashboard/acot-server/db/connection.js @@ -0,0 +1,297 @@ +const { Client } = require('ssh2'); +const mysql = require('mysql2/promise'); +const fs = require('fs'); + +// Connection pool configuration +const connectionPool = { + connections: [], + maxConnections: 20, + currentConnections: 0, + pendingRequests: [], + // Cache for query results (key: query string, value: {data, timestamp}) + queryCache: new Map(), + // Cache duration for different query types in milliseconds + cacheDuration: { + 'stats': 60 * 1000, // 1 minute for stats + 'products': 5 * 60 * 1000, // 5 minutes for products + 'orders': 60 * 1000, // 1 minute for orders + 'default': 60 * 1000 // 1 minute default + }, + // Circuit breaker state + circuitBreaker: { + failures: 0, + lastFailure: 0, + isOpen: false, + threshold: 5, + timeout: 30000 // 30 seconds + } +}; + +/** + * Get a database connection from the pool + * @returns {Promise<{connection: object, release: function}>} The database connection and release function + */ +async function getDbConnection() { + return new Promise(async (resolve, reject) => { + // Check circuit breaker + const now = Date.now(); + if (connectionPool.circuitBreaker.isOpen) { + if (now - connectionPool.circuitBreaker.lastFailure > connectionPool.circuitBreaker.timeout) { + // Reset circuit breaker + connectionPool.circuitBreaker.isOpen = false; + connectionPool.circuitBreaker.failures = 0; + console.log('Circuit breaker reset'); + } else { + reject(new Error('Circuit breaker is open - too many connection failures')); + return; + } + } + + // Check if there's an available connection in the pool + if (connectionPool.connections.length > 0) { + const conn = connectionPool.connections.pop(); + console.log(`Using pooled connection. Pool size: ${connectionPool.connections.length}`); + resolve({ + connection: conn.connection, + release: () => releaseConnection(conn) + }); + return; + } + + // If we haven't reached max connections, create a new one + if (connectionPool.currentConnections < connectionPool.maxConnections) { + try { + console.log(`Creating new connection. Current: ${connectionPool.currentConnections}/${connectionPool.maxConnections}`); + connectionPool.currentConnections++; + + const tunnel = await setupSshTunnel(); + const { ssh, stream, dbConfig } = tunnel; + + const connection = await mysql.createConnection({ + ...dbConfig, + stream + }); + + const conn = { ssh, connection, inUse: true, created: Date.now() }; + + console.log('Database connection established'); + + // Reset circuit breaker on successful connection + if (connectionPool.circuitBreaker.failures > 0) { + connectionPool.circuitBreaker.failures = 0; + connectionPool.circuitBreaker.isOpen = false; + } + + resolve({ + connection: conn.connection, + release: () => releaseConnection(conn) + }); + } catch (error) { + connectionPool.currentConnections--; + + // Track circuit breaker failures + connectionPool.circuitBreaker.failures++; + connectionPool.circuitBreaker.lastFailure = Date.now(); + + if (connectionPool.circuitBreaker.failures >= connectionPool.circuitBreaker.threshold) { + connectionPool.circuitBreaker.isOpen = true; + console.log(`Circuit breaker opened after ${connectionPool.circuitBreaker.failures} failures`); + } + + reject(error); + } + return; + } + + // Pool is full, queue the request with timeout + console.log('Connection pool full, queuing request...'); + const timeoutId = setTimeout(() => { + // Remove from queue if still there + const index = connectionPool.pendingRequests.findIndex(req => req.resolve === resolve); + if (index !== -1) { + connectionPool.pendingRequests.splice(index, 1); + reject(new Error('Connection pool queue timeout after 15 seconds')); + } + }, 15000); + + connectionPool.pendingRequests.push({ + resolve, + reject, + timeoutId, + timestamp: Date.now() + }); + }); +} + +/** + * Release a connection back to the pool + */ +function releaseConnection(conn) { + conn.inUse = false; + + // Check if there are pending requests + if (connectionPool.pendingRequests.length > 0) { + const { resolve, timeoutId } = connectionPool.pendingRequests.shift(); + + // Clear the timeout since we're serving the request + if (timeoutId) { + clearTimeout(timeoutId); + } + + conn.inUse = true; + console.log(`Serving queued request. Queue length: ${connectionPool.pendingRequests.length}`); + resolve({ + connection: conn.connection, + release: () => releaseConnection(conn) + }); + } else { + // Return to pool + connectionPool.connections.push(conn); + console.log(`Connection returned to pool. Pool size: ${connectionPool.connections.length}, Active: ${connectionPool.currentConnections}`); + } +} + +/** + * Get cached query results or execute query if not cached + * @param {string} cacheKey - Unique key to identify the query + * @param {string} queryType - Type of query (stats, products, orders, etc.) + * @param {Function} queryFn - Function to execute if cache miss + * @returns {Promise} The query result + */ +async function getCachedQuery(cacheKey, queryType, queryFn) { + // Get cache duration based on query type + const cacheDuration = connectionPool.cacheDuration[queryType] || connectionPool.cacheDuration.default; + + // Check if we have a valid cached result + const cachedResult = connectionPool.queryCache.get(cacheKey); + const now = Date.now(); + + if (cachedResult && (now - cachedResult.timestamp < cacheDuration)) { + console.log(`Cache hit for ${queryType} query: ${cacheKey}`); + return cachedResult.data; + } + + // No valid cache found, execute the query + console.log(`Cache miss for ${queryType} query: ${cacheKey}`); + const result = await queryFn(); + + // Cache the result + connectionPool.queryCache.set(cacheKey, { + data: result, + timestamp: now + }); + + return result; +} + +/** + * Setup SSH tunnel to production database + * @private - Should only be used by getDbConnection + * @returns {Promise<{ssh: object, stream: object, dbConfig: object}>} + */ +async function setupSshTunnel() { + const sshConfig = { + host: process.env.PROD_SSH_HOST, + port: process.env.PROD_SSH_PORT || 22, + username: process.env.PROD_SSH_USER, + privateKey: process.env.PROD_SSH_KEY_PATH + ? fs.readFileSync(process.env.PROD_SSH_KEY_PATH) + : undefined, + compress: true + }; + + const dbConfig = { + host: process.env.PROD_DB_HOST || 'localhost', + user: process.env.PROD_DB_USER, + password: process.env.PROD_DB_PASSWORD, + database: process.env.PROD_DB_NAME, + port: process.env.PROD_DB_PORT || 3306, + timezone: 'Z' + }; + + return new Promise((resolve, reject) => { + const ssh = new Client(); + + ssh.on('error', (err) => { + console.error('SSH connection error:', err); + reject(err); + }); + + ssh.on('ready', () => { + ssh.forwardOut( + '127.0.0.1', + 0, + dbConfig.host, + dbConfig.port, + (err, stream) => { + if (err) reject(err); + resolve({ ssh, stream, dbConfig }); + } + ); + }).connect(sshConfig); + }); +} + +/** + * Clear cached query results + * @param {string} [cacheKey] - Specific cache key to clear (clears all if not provided) + */ +function clearQueryCache(cacheKey) { + if (cacheKey) { + connectionPool.queryCache.delete(cacheKey); + console.log(`Cleared cache for key: ${cacheKey}`); + } else { + connectionPool.queryCache.clear(); + console.log('Cleared all query cache'); + } +} + +/** + * Force close all active connections + * Useful for server shutdown or manual connection reset + */ +async function closeAllConnections() { + // Close all pooled connections + for (const conn of connectionPool.connections) { + try { + await conn.connection.end(); + conn.ssh.end(); + console.log('Closed pooled connection'); + } catch (error) { + console.error('Error closing pooled connection:', error); + } + } + + // Reset pool state + connectionPool.connections = []; + connectionPool.currentConnections = 0; + connectionPool.pendingRequests = []; + connectionPool.queryCache.clear(); + + console.log('All connections closed and pool reset'); +} + +/** + * Get connection pool status for debugging + */ +function getPoolStatus() { + return { + poolSize: connectionPool.connections.length, + activeConnections: connectionPool.currentConnections, + maxConnections: connectionPool.maxConnections, + pendingRequests: connectionPool.pendingRequests.length, + cacheSize: connectionPool.queryCache.size, + queuedRequests: connectionPool.pendingRequests.map(req => ({ + waitTime: Date.now() - req.timestamp, + hasTimeout: !!req.timeoutId + })) + }; +} + +module.exports = { + getDbConnection, + getCachedQuery, + clearQueryCache, + closeAllConnections, + getPoolStatus +}; \ No newline at end of file diff --git a/inventory-server/dashboard/acot-server/package-lock.json b/inventory-server/dashboard/acot-server/package-lock.json new file mode 100644 index 0000000..aa52173 --- /dev/null +++ b/inventory-server/dashboard/acot-server/package-lock.json @@ -0,0 +1,1553 @@ +{ + "name": "acot-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "acot-server", + "version": "1.0.0", + "dependencies": { + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "luxon": "^3.5.0", + "morgan": "^1.10.0", + "mysql2": "^3.6.5", + "ssh2": "^1.14.0" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "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/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": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "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/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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": "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/mime-types/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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz", + "integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/nan": { + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", + "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "license": "MIT", + "optional": true + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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" + } + } + } +} diff --git a/inventory-server/dashboard/acot-server/package.json b/inventory-server/dashboard/acot-server/package.json new file mode 100644 index 0000000..ca8eda2 --- /dev/null +++ b/inventory-server/dashboard/acot-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "acot-server", + "version": "1.0.0", + "description": "A Cherry On Top production database server", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "morgan": "^1.10.0", + "ssh2": "^1.14.0", + "mysql2": "^3.6.5", + "compression": "^1.7.4", + "luxon": "^3.5.0" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} diff --git a/inventory-server/dashboard/acot-server/routes/discounts.js b/inventory-server/dashboard/acot-server/routes/discounts.js new file mode 100644 index 0000000..2ef7a3b --- /dev/null +++ b/inventory-server/dashboard/acot-server/routes/discounts.js @@ -0,0 +1,546 @@ +const express = require('express'); +const { DateTime } = require('luxon'); +const { getDbConnection } = require('../db/connection'); + +const router = express.Router(); + +const RANGE_BOUNDS = [ + 10, 20, 30, 40, 50, 60, 70, 80, 90, + 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, + 300, 400, 500, 1000, 1500, 2000 +]; + +const FINAL_BUCKET_KEY = 'PLUS'; + +function buildRangeDefinitions() { + const ranges = []; + let previous = 0; + for (const bound of RANGE_BOUNDS) { + const label = `$${previous.toLocaleString()} - $${bound.toLocaleString()}`; + const key = bound.toString().padStart(5, '0'); + ranges.push({ + min: previous, + max: bound, + label, + key, + sort: bound + }); + previous = bound; + } + // Remove the 2000+ category - all orders >2000 will go into the 2000 bucket + return ranges; +} + +const RANGE_DEFINITIONS = buildRangeDefinitions(); + +const BUCKET_CASE = (() => { + const parts = []; + for (let i = 0; i < RANGE_BOUNDS.length; i++) { + const bound = RANGE_BOUNDS[i]; + const key = bound.toString().padStart(5, '0'); + if (i === RANGE_BOUNDS.length - 1) { + // For the last bucket (2000), include all orders >= 1500 (previous bound) + parts.push(`ELSE '${key}'`); + } else { + parts.push(`WHEN o.summary_subtotal <= ${bound} THEN '${key}'`); + } + } + return `CASE\n ${parts.join('\n ')}\n END`; +})(); + +const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5, so 200 points = $1 + +const DEFAULTS = { + merchantFeePercent: 2.9, + fixedCostPerOrder: 1.5, + pointsPerDollar: 0, + pointsRedemptionRate: 0, // Will be calculated from actual data + pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE, +}; + +function parseDate(value, fallback) { + if (!value) { + return fallback; + } + const parsed = DateTime.fromISO(value); + if (!parsed.isValid) { + return fallback; + } + return parsed; +} + +function formatDateForSql(dt) { + return dt.toFormat('yyyy-LL-dd HH:mm:ss'); +} + +function getMidpoint(range) { + if (range.max == null) { + return range.min + 200; // Rough estimate for 2000+ + } + return (range.min + range.max) / 2; +} + +router.get('/promos', async (req, res) => { + let connection; + try { + const { connection: conn, release } = await getDbConnection(); + connection = conn; + const releaseConnection = release; + + const { startDate, endDate } = req.query || {}; + const now = DateTime.now().endOf('day'); + const defaultStart = now.minus({ years: 3 }).startOf('day'); + + const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart; + const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now; + + const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd; + const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart; + + const rangeStartSql = formatDateForSql(rangeStart); + const rangeEndSql = formatDateForSql(rangeEnd); + + const sql = ` + SELECT + p.promo_id AS id, + p.promo_code AS code, + p.promo_description_online AS description_online, + p.promo_description_private AS description_private, + p.date_start, + p.date_end, + COALESCE(u.usage_count, 0) AS usage_count + FROM promos p + LEFT JOIN ( + SELECT + discount_code, + COUNT(DISTINCT order_id) AS usage_count + FROM order_discounts + WHERE discount_type = 10 AND discount_active = 1 + GROUP BY discount_code + ) u ON u.discount_code = p.promo_id + WHERE p.date_start IS NOT NULL + AND p.date_end IS NOT NULL + AND NOT (p.date_end < ? OR p.date_start > ?) + AND p.store = 1 + AND p.date_start >= '2010-01-01' + ORDER BY p.promo_id DESC + LIMIT 200 + `; + + const [rows] = await connection.execute(sql, [rangeStartSql, rangeEndSql]); + releaseConnection(); + + const promos = rows.map(row => ({ + id: Number(row.id), + code: row.code, + description: row.description_online || row.description_private || '', + privateDescription: row.description_private || '', + promo_description_online: row.description_online || '', + promo_description_private: row.description_private || '', + dateStart: row.date_start, + dateEnd: row.date_end, + usageCount: Number(row.usage_count || 0) + })); + + res.json({ promos }); + } catch (error) { + if (connection) { + try { + connection.destroy(); + } catch (destroyError) { + console.error('Failed to destroy connection after error:', destroyError); + } + } + console.error('Error fetching promos:', error); + res.status(500).json({ error: 'Failed to fetch promos' }); + } +}); + +router.post('/simulate', async (req, res) => { + const { + dateRange = {}, + filters = {}, + productPromo = {}, + shippingPromo = {}, + shippingTiers = [], + merchantFeePercent, + fixedCostPerOrder, + cogsCalculationMode = 'actual', + pointsConfig = {} + } = req.body || {}; + + const endDefault = DateTime.now(); + const startDefault = endDefault.minus({ months: 6 }); + const startDt = parseDate(dateRange.start, startDefault).startOf('day'); + const endDt = parseDate(dateRange.end, endDefault).endOf('day'); + + const shipCountry = filters.shipCountry || 'US'; + const rawPromoFilters = [ + ...(Array.isArray(filters.promoIds) ? filters.promoIds : []), + ...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []), + ]; + const promoCodes = Array.from( + new Set( + rawPromoFilters + .map((value) => { + if (typeof value === 'string') { + return value.trim(); + } + if (typeof value === 'number') { + return String(value); + } + return ''; + }) + .filter((value) => value.length > 0) + ) + ); + + const config = { + merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent, + fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder, + productPromo: { + type: productPromo.type || 'none', + value: Number(productPromo.value || 0), + minSubtotal: Number(productPromo.minSubtotal || 0) + }, + shippingPromo: { + type: shippingPromo.type || 'none', + value: Number(shippingPromo.value || 0), + minSubtotal: Number(shippingPromo.minSubtotal || 0), + maxDiscount: Number(shippingPromo.maxDiscount || 0) + }, + shippingTiers: Array.isArray(shippingTiers) + ? shippingTiers + .map(tier => ({ + threshold: Number(tier.threshold || 0), + mode: tier.mode === 'percentage' || tier.mode === 'flat' ? tier.mode : 'percentage', + value: Number(tier.value || 0) + })) + .filter(tier => tier.threshold >= 0 && tier.value >= 0) + .sort((a, b) => a.threshold - b.threshold) + : [], + points: { + pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null, + redemptionRate: typeof pointsConfig.redemptionRate === 'number' ? pointsConfig.redemptionRate : null, + pointDollarValue: typeof pointsConfig.pointDollarValue === 'number' + ? pointsConfig.pointDollarValue + : DEFAULT_POINT_DOLLAR_VALUE + } + }; + + let connection; + let release; + + try { + const dbConn = await getDbConnection(); + connection = dbConn.connection; + release = dbConn.release; + + const filteredOrdersParams = [ + shipCountry, + formatDateForSql(startDt), + formatDateForSql(endDt) + ]; + const promoJoin = promoCodes.length > 0 + ? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10' + : ''; + + let promoFilterClause = ''; + if (promoCodes.length > 0) { + const placeholders = promoCodes.map(() => '?').join(','); + promoFilterClause = `AND od.discount_code IN (${placeholders})`; + filteredOrdersParams.push(...promoCodes); + } + + const filteredOrdersQuery = ` + SELECT + o.order_id, + o.order_cid, + o.summary_subtotal, + o.summary_discount_subtotal, + o.summary_shipping, + o.ship_method_rate, + o.ship_method_cost, + o.summary_points, + ${BUCKET_CASE} AS bucket_key + FROM _order o + ${promoJoin} + WHERE o.summary_shipping > 0 + AND o.summary_total > 0 + AND o.order_status NOT IN (15) + AND o.ship_method_selected <> 'holdit' + AND o.ship_country = ? + AND o.date_placed BETWEEN ? AND ? + ${promoFilterClause} + `; + + const bucketParams = [ + ...filteredOrdersParams, + formatDateForSql(startDt), + formatDateForSql(endDt) + ]; + + const bucketQuery = ` + SELECT + f.bucket_key, + COUNT(*) AS order_count, + SUM(f.summary_subtotal) AS subtotal_sum, + SUM(f.summary_discount_subtotal) AS product_discount_sum, + SUM(f.summary_subtotal + f.summary_discount_subtotal) AS regular_subtotal_sum, + SUM(f.ship_method_rate) AS ship_rate_sum, + SUM(f.ship_method_cost) AS ship_cost_sum, + SUM(f.summary_points) AS points_awarded_sum, + SUM(COALESCE(p.points_redeemed, 0)) AS points_redeemed_sum, + SUM(COALESCE(c.total_cogs, 0)) AS cogs_sum, + AVG(f.summary_subtotal) AS avg_subtotal, + AVG(f.summary_discount_subtotal) AS avg_product_discount, + AVG(f.ship_method_rate) AS avg_ship_rate, + AVG(f.ship_method_cost) AS avg_ship_cost, + AVG(COALESCE(c.total_cogs, 0)) AS avg_cogs + FROM ( + ${filteredOrdersQuery} + ) AS f + LEFT JOIN ( + SELECT order_id, SUM(cogs_amount) AS total_cogs + FROM report_sales_data + WHERE action IN (1,2,3) + AND date_change BETWEEN ? AND ? + GROUP BY order_id + ) AS c ON c.order_id = f.order_id + LEFT JOIN ( + SELECT order_id, SUM(discount_amount) AS points_redeemed + FROM order_discounts + WHERE discount_type = 20 AND discount_active = 1 + GROUP BY order_id + ) AS p ON p.order_id = f.order_id + GROUP BY f.bucket_key + `; + + const [rows] = await connection.execute(bucketQuery, bucketParams); + + const totals = { + orders: 0, + subtotal: 0, + productDiscount: 0, + regularSubtotal: 0, + shipRate: 0, + shipCost: 0, + cogs: 0, + pointsAwarded: 0, + pointsRedeemed: 0 + }; + + const rowMap = new Map(); + for (const row of rows) { + const key = row.bucket_key || FINAL_BUCKET_KEY; + const parsed = { + orderCount: Number(row.order_count || 0), + subtotalSum: Number(row.subtotal_sum || 0), + productDiscountSum: Number(row.product_discount_sum || 0), + regularSubtotalSum: Number(row.regular_subtotal_sum || 0), + shipRateSum: Number(row.ship_rate_sum || 0), + shipCostSum: Number(row.ship_cost_sum || 0), + pointsAwardedSum: Number(row.points_awarded_sum || 0), + pointsRedeemedSum: Number(row.points_redeemed_sum || 0), + cogsSum: Number(row.cogs_sum || 0), + avgSubtotal: Number(row.avg_subtotal || 0), + avgProductDiscount: Number(row.avg_product_discount || 0), + avgShipRate: Number(row.avg_ship_rate || 0), + avgShipCost: Number(row.avg_ship_cost || 0), + avgCogs: Number(row.avg_cogs || 0) + }; + rowMap.set(key, parsed); + + totals.orders += parsed.orderCount; + totals.subtotal += parsed.subtotalSum; + totals.productDiscount += parsed.productDiscountSum; + totals.regularSubtotal += parsed.regularSubtotalSum; + totals.shipRate += parsed.shipRateSum; + totals.shipCost += parsed.shipCostSum; + totals.cogs += parsed.cogsSum; + totals.pointsAwarded += parsed.pointsAwardedSum; + totals.pointsRedeemed += parsed.pointsRedeemedSum; + } + + const productDiscountRate = totals.regularSubtotal > 0 + ? totals.productDiscount / totals.regularSubtotal + : 0; + + const pointsPerDollar = config.points.pointsPerDollar != null + ? config.points.pointsPerDollar + : totals.subtotal > 0 + ? totals.pointsAwarded / totals.subtotal + : 0; + + const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE; + + // Calculate redemption rate using dollars redeemed from the matched order set + let calculatedRedemptionRate = 0; + if (config.points.redemptionRate != null) { + calculatedRedemptionRate = config.points.redemptionRate; + } else if (totals.pointsAwarded > 0 && pointDollarValue > 0) { + const totalRedeemedPoints = totals.pointsRedeemed / pointDollarValue; + if (totalRedeemedPoints > 0) { + calculatedRedemptionRate = Math.min(1, totalRedeemedPoints / totals.pointsAwarded); + } + } + + const redemptionRate = calculatedRedemptionRate; + + // Calculate overall average COGS percentage for 'average' mode + let overallCogsPercentage = 0; + if (cogsCalculationMode === 'average' && totals.subtotal > 0) { + overallCogsPercentage = totals.cogs / totals.subtotal; + } + + const bucketResults = []; + let weightedProfitAmount = 0; + let weightedProfitPercent = 0; + + for (const range of RANGE_DEFINITIONS) { + const data = rowMap.get(range.key) || { + orderCount: 0, + avgSubtotal: 0, + avgShipRate: 0, + avgShipCost: 0, + avgCogs: 0 + }; + + const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range); + const shippingChargeBase = data.avgShipRate > 0 ? data.avgShipRate : 0; + const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0; + + // Calculate COGS based on the selected mode + let productCogs; + if (cogsCalculationMode === 'average') { + // Use overall average COGS percentage applied to this bucket's order value + productCogs = orderValue * overallCogsPercentage; + } else { + // Use actual COGS data from this bucket (existing behavior) + productCogs = data.avgCogs > 0 ? data.avgCogs : 0; + } + const productDiscountAmount = orderValue * productDiscountRate; + const effectiveRegularPrice = productDiscountRate < 0.99 + ? orderValue / (1 - productDiscountRate) + : orderValue; + + let promoProductDiscount = 0; + if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) { + promoProductDiscount = Math.min(orderValue, (config.productPromo.value / 100) * orderValue); + } else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) { + const targetRate = config.productPromo.value / 100; + const additionalRate = Math.max(0, targetRate - productDiscountRate); + promoProductDiscount = Math.min(orderValue, additionalRate * effectiveRegularPrice); + } else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) { + promoProductDiscount = Math.min(orderValue, config.productPromo.value); + } + + let shippingAfterAuto = shippingChargeBase; + for (const tier of config.shippingTiers) { + if (orderValue >= tier.threshold) { + if (tier.mode === 'percentage') { + shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100); + } else if (tier.mode === 'flat') { + shippingAfterAuto = tier.value; + } + } + } + + let shipPromoDiscount = 0; + if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) { + if (config.shippingPromo.type === 'percentage') { + shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100); + } else if (config.shippingPromo.type === 'fixed') { + shipPromoDiscount = config.shippingPromo.value; + } + if (config.shippingPromo.maxDiscount > 0) { + shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount); + } + shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto); + } + + const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount); + const customerItemCost = Math.max(0, orderValue - promoProductDiscount); + const totalRevenue = customerItemCost + customerShipCost; + + const merchantFees = totalRevenue * (config.merchantFeePercent / 100); + const pointsCost = customerItemCost * pointsPerDollar * redemptionRate * pointDollarValue; + const fixedCosts = config.fixedCostPerOrder; + const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts; + const profit = totalRevenue - totalCosts; + const profitPercent = totalRevenue > 0 ? (profit / totalRevenue) : 0; + const weight = totals.orders > 0 ? (data.orderCount || 0) / totals.orders : 0; + + weightedProfitAmount += profit * weight; + weightedProfitPercent += profitPercent * weight; + + bucketResults.push({ + key: range.key, + label: range.label, + min: range.min, + max: range.max, + orderCount: data.orderCount || 0, + weight, + orderValue, + productDiscountAmount, + promoProductDiscount, + customerItemCost, + shippingChargeBase, + shippingAfterAuto, + shipPromoDiscount, + customerShipCost, + actualShippingCost, + totalRevenue, + productCogs, + merchantFees, + pointsCost, + fixedCosts, + totalCosts, + profit, + profitPercent + }); + } + + if (release) { + release(); + } + + res.json({ + dateRange: { + start: startDt.toISO(), + end: endDt.toISO() + }, + totals: { + orders: totals.orders, + subtotal: totals.subtotal, + productDiscountRate, + pointsPerDollar, + redemptionRate, + pointDollarValue, + weightedProfitAmount, + weightedProfitPercent, + overallCogsPercentage: cogsCalculationMode === 'average' ? overallCogsPercentage : undefined + }, + buckets: bucketResults + }); + } catch (error) { + if (release) { + try { + release(); + } catch (releaseError) { + console.error('Failed to release connection after error:', releaseError); + } + } else if (connection) { + try { + connection.destroy(); + } catch (destroyError) { + console.error('Failed to destroy connection after error:', destroyError); + } + } + + console.error('Error running discount simulation:', error); + res.status(500).json({ error: 'Failed to run discount simulation' }); + } +}); + +module.exports = router; diff --git a/inventory-server/dashboard/acot-server/routes/events.js b/inventory-server/dashboard/acot-server/routes/events.js index 11250ca..5d03ba9 100644 --- a/inventory-server/dashboard/acot-server/routes/events.js +++ b/inventory-server/dashboard/acot-server/routes/events.js @@ -1,7 +1,17 @@ const express = require('express'); +const { DateTime } = require('luxon'); + const router = express.Router(); const { getDbConnection, getPoolStatus } = require('../db/connection'); -const { getTimeRangeConditions, formatBusinessDate, getBusinessDayBounds } = require('../utils/timeUtils'); +const { + getTimeRangeConditions, + formatBusinessDate, + getBusinessDayBounds, + _internal: timeHelpers +} = require('../utils/timeUtils'); + +const TIMEZONE = 'America/New_York'; +const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1; // Image URL generation utility const getImageUrls = (pid, iid = 1) => { @@ -419,7 +429,25 @@ router.get('/financials', async (req, res) => { release = releaseConn; const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); - const financialWhere = whereClause.replace(/date_placed/g, 'DATE_SUB(date_change, INTERVAL 1 HOUR)'); + const financialWhere = whereClause.replace(/date_placed/g, 'date_change'); + + const formatDebugBound = (value) => { + if (!value) return 'n/a'; + const parsed = DateTime.fromSQL(value, { zone: 'UTC-05:00' }); + if (!parsed.isValid) { + return `invalid(${value})`; + } + return parsed.setZone(TIMEZONE).toISO(); + }; + + console.log('[FINANCIALS] request params', { + timeRange: timeRange || 'default', + startDate, + endDate, + whereClause: financialWhere, + params, + boundsEastern: Array.isArray(params) ? params.map(formatDebugBound) : [], + }); const [totalsRows] = await connection.execute( buildFinancialTotalsQuery(financialWhere), @@ -428,6 +456,11 @@ router.get('/financials', async (req, res) => { const totals = normalizeFinancialTotals(totalsRows[0]); + console.log('[FINANCIALS] totals query result', { + rows: totalsRows.length, + totals, + }); + const [trendRows] = await connection.execute( buildFinancialTrendQuery(financialWhere), params @@ -435,12 +468,26 @@ router.get('/financials', async (req, res) => { const trend = trendRows.map(normalizeFinancialTrendRow); + console.log('[FINANCIALS] trend query result', { + rows: trendRows.length, + first: trend[0] || null, + last: trend[trend.length - 1] || null, + }); + let previousTotals = null; let comparison = null; const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate); if (previousRange) { - const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'DATE_SUB(date_change, INTERVAL 1 HOUR)'); + console.log('[FINANCIALS] previous range params', { + timeRange: timeRange || 'default', + prevWhere: previousRange.whereClause.replace(/date_placed/g, 'date_change'), + params: previousRange.params, + boundsEastern: Array.isArray(previousRange.params) + ? previousRange.params.map(formatDebugBound) + : [], + }); + const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change'); const [previousRows] = await connection.execute( buildFinancialTotalsQuery(prevWhere), previousRange.params @@ -458,12 +505,37 @@ router.get('/financials', async (req, res) => { }; } + const trendDebugSample = trend.slice(-3).map((item) => ({ + date: item.date, + timestamp: item.timestamp, + income: item.income, + grossSales: item.grossSales, + })); + + const debugInfo = { + serverTimeUtc: new Date().toISOString(), + timeRange: timeRange || 'default', + params, + boundsEastern: Array.isArray(params) ? params.map(formatDebugBound) : [], + trendCount: trend.length, + trendSample: trendDebugSample, + previousRange: previousRange + ? { + params: previousRange.params, + boundsEastern: Array.isArray(previousRange.params) + ? previousRange.params.map(formatDebugBound) + : [], + } + : null, + }; + res.json({ dateRange, totals, previousTotals, comparison, trend, + debug: debugInfo, }); } catch (error) { console.error('Error in /financials:', error); @@ -662,44 +734,35 @@ function processShippingData(shippingResult, totalShipped) { } function calculatePeriodProgress(timeRange) { - const now = new Date(); - const easternTime = new Date(now.getTime() - (5 * 60 * 60 * 1000)); // UTC-5 - - switch (timeRange) { - case 'today': { - const { start } = getBusinessDayBounds('today'); - const businessStart = new Date(start); - const businessEnd = new Date(businessStart); - businessEnd.setDate(businessEnd.getDate() + 1); - businessEnd.setHours(0, 59, 59, 999); // 12:59 AM next day - - const elapsed = easternTime.getTime() - businessStart.getTime(); - const total = businessEnd.getTime() - businessStart.getTime(); - return Math.min(100, Math.max(0, (elapsed / total) * 100)); - } - case 'thisWeek': { - const startOfWeek = new Date(easternTime); - startOfWeek.setDate(easternTime.getDate() - easternTime.getDay()); // Sunday - startOfWeek.setHours(1, 0, 0, 0); // 1 AM business day start - - const endOfWeek = new Date(startOfWeek); - endOfWeek.setDate(endOfWeek.getDate() + 7); - - const elapsed = easternTime.getTime() - startOfWeek.getTime(); - const total = endOfWeek.getTime() - startOfWeek.getTime(); - return Math.min(100, Math.max(0, (elapsed / total) * 100)); - } - case 'thisMonth': { - const startOfMonth = new Date(easternTime.getFullYear(), easternTime.getMonth(), 1, 1, 0, 0, 0); - const endOfMonth = new Date(easternTime.getFullYear(), easternTime.getMonth() + 1, 1, 0, 59, 59, 999); - - const elapsed = easternTime.getTime() - startOfMonth.getTime(); - const total = endOfMonth.getTime() - startOfMonth.getTime(); - return Math.min(100, Math.max(0, (elapsed / total) * 100)); - } - default: - return 100; + if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) { + return 100; } + + const now = DateTime.now().setZone(TIMEZONE); + + let range; + try { + range = timeHelpers.getRangeForTimeRange(timeRange, now); + } catch (error) { + console.error(`[STATS] Failed to derive range for ${timeRange}:`, error); + return 100; + } + + if (!range?.start || !range?.end) { + return 100; + } + + const total = range.end.toMillis() - range.start.toMillis(); + if (total <= 0) { + return 100; + } + + const elapsed = Math.min( + Math.max(now.toMillis() - range.start.toMillis(), 0), + total + ); + + return Math.min(100, Math.max(0, (elapsed / total) * 100)); } function buildFinancialTotalsQuery(whereClause) { @@ -718,9 +781,13 @@ function buildFinancialTotalsQuery(whereClause) { } function buildFinancialTrendQuery(whereClause) { + const businessDayOffset = BUSINESS_DAY_START_HOUR; return ` SELECT - DATE(DATE_SUB(date_change, INTERVAL 1 HOUR)) as date, + DATE_FORMAT( + DATE_SUB(date_change, INTERVAL ${businessDayOffset} HOUR), + '%Y-%m-%d' + ) as businessDate, SUM(sale_amount) as grossSales, SUM(refund_amount) as refunds, SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount) as shippingFees, @@ -730,8 +797,8 @@ function buildFinancialTrendQuery(whereClause) { FROM report_sales_data WHERE ${whereClause} AND action IN (1, 2, 3) - GROUP BY DATE(DATE_SUB(date_change, INTERVAL 1 HOUR)) - ORDER BY date ASC + GROUP BY businessDate + ORDER BY businessDate ASC `; } @@ -772,16 +839,44 @@ function normalizeFinancialTrendRow(row = {}) { const profit = income - cogs; const margin = income !== 0 ? (profit / income) * 100 : 0; let timestamp = null; - let dateValue = null; + let dateValue = row.businessDate || row.date || null; - if (row.date instanceof Date) { - dateValue = row.date.toISOString().slice(0, 10); + const resolveBusinessDayStart = (value) => { + if (!value) { + return null; + } + + let dt; + if (value instanceof Date) { + dt = DateTime.fromJSDate(value, { zone: TIMEZONE }); + } else if (typeof value === 'string') { + dt = DateTime.fromISO(value, { zone: TIMEZONE }); + if (!dt.isValid) { + dt = DateTime.fromSQL(value, { zone: TIMEZONE }); + } + } + + if (!dt || !dt.isValid) { + return null; + } + + const hour = BUSINESS_DAY_START_HOUR; + return dt.set({ + hour, + minute: 0, + second: 0, + millisecond: 0, + }); + }; + + const businessDayStart = resolveBusinessDayStart(dateValue); + if (businessDayStart) { + timestamp = businessDayStart.toUTC().toISO(); + dateValue = businessDayStart.toISO(); + } else if (row.date instanceof Date) { + timestamp = new Date(row.date.getTime()).toISOString(); } else if (typeof row.date === 'string') { - dateValue = row.date; - } - - if (typeof dateValue === 'string') { - timestamp = new Date(`${dateValue}T06:00:00.000Z`).toISOString(); + timestamp = new Date(`${row.date}T00:00:00Z`).toISOString(); } return { diff --git a/inventory-server/dashboard/acot-server/routes/test.js b/inventory-server/dashboard/acot-server/routes/test.js new file mode 100644 index 0000000..e41e8ca --- /dev/null +++ b/inventory-server/dashboard/acot-server/routes/test.js @@ -0,0 +1,57 @@ +const express = require('express'); +const router = express.Router(); +const { getDbConnection, getCachedQuery } = require('../db/connection'); + +// Test endpoint to count orders +router.get('/order-count', async (req, res) => { + try { + const { connection } = await getDbConnection(); + + // Simple query to count orders from _order table + const queryFn = async () => { + const [rows] = await connection.execute('SELECT COUNT(*) as count FROM _order'); + return rows[0].count; + }; + + const cacheKey = 'order-count'; + const count = await getCachedQuery(cacheKey, 'default', queryFn); + + res.json({ + success: true, + data: { + orderCount: count, + timestamp: new Date().toISOString() + } + }); + } catch (error) { + console.error('Error fetching order count:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// Test connection endpoint +router.get('/test-connection', async (req, res) => { + try { + const { connection } = await getDbConnection(); + + // Test the connection with a simple query + const [rows] = await connection.execute('SELECT 1 as test'); + + res.json({ + success: true, + message: 'Database connection successful', + data: rows[0] + }); + } catch (error) { + console.error('Error testing connection:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/dashboard/acot-server/server.js b/inventory-server/dashboard/acot-server/server.js new file mode 100644 index 0000000..4a70601 --- /dev/null +++ b/inventory-server/dashboard/acot-server/server.js @@ -0,0 +1,99 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const morgan = require('morgan'); +const compression = require('compression'); +const fs = require('fs'); +const path = require('path'); +const { closeAllConnections } = require('./db/connection'); + +const app = express(); +const PORT = process.env.ACOT_PORT || 3012; + +// Create logs directory if it doesn't exist +const logDir = path.join(__dirname, 'logs/app'); +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); +} + +// Create a write stream for access logs +const accessLogStream = fs.createWriteStream( + path.join(logDir, 'access.log'), + { flags: 'a' } +); + +// Middleware +app.use(compression()); +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Logging middleware +if (process.env.NODE_ENV === 'production') { + app.use(morgan('combined', { stream: accessLogStream })); +} else { + app.use(morgan('dev')); +} + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + service: 'acot-server', + timestamp: new Date().toISOString(), + uptime: process.uptime() + }); +}); + +// Routes +app.use('/api/acot/test', require('./routes/test')); +app.use('/api/acot/events', require('./routes/events')); +app.use('/api/acot/discounts', require('./routes/discounts')); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ + success: false, + error: process.env.NODE_ENV === 'production' + ? 'Internal server error' + : err.message + }); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ + success: false, + error: 'Route not found' + }); +}); + +// Start server +const server = app.listen(PORT, () => { + console.log(`ACOT Server running on port ${PORT}`); + console.log(`Environment: ${process.env.NODE_ENV}`); +}); + +// Graceful shutdown +const gracefulShutdown = async () => { + console.log('SIGTERM signal received: closing HTTP server'); + server.close(async () => { + console.log('HTTP server closed'); + + // Close database connections + try { + await closeAllConnections(); + console.log('Database connections closed'); + } catch (error) { + console.error('Error closing database connections:', error); + } + + process.exit(0); + }); +}; + +process.on('SIGTERM', gracefulShutdown); +process.on('SIGINT', gracefulShutdown); + +module.exports = app; diff --git a/inventory-server/dashboard/acot-server/utils/timeUtils.js b/inventory-server/dashboard/acot-server/utils/timeUtils.js new file mode 100644 index 0000000..29fdfae --- /dev/null +++ b/inventory-server/dashboard/acot-server/utils/timeUtils.js @@ -0,0 +1,312 @@ +const { DateTime } = require('luxon'); + +const TIMEZONE = 'America/New_York'; +const DB_TIMEZONE = 'UTC-05:00'; +const BUSINESS_DAY_START_HOUR = 1; // 1 AM Eastern +const WEEK_START_DAY = 7; // Sunday (Luxon uses 1 = Monday, 7 = Sunday) +const DB_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss'; + +const isDateTime = (value) => DateTime.isDateTime(value); + +const ensureDateTime = (value, { zone = TIMEZONE } = {}) => { + if (!value) return null; + + if (isDateTime(value)) { + return value.setZone(zone); + } + + if (value instanceof Date) { + return DateTime.fromJSDate(value, { zone }); + } + + if (typeof value === 'number') { + return DateTime.fromMillis(value, { zone }); + } + + if (typeof value === 'string') { + let dt = DateTime.fromISO(value, { zone, setZone: true }); + if (!dt.isValid) { + dt = DateTime.fromSQL(value, { zone }); + } + return dt.isValid ? dt : null; + } + + return null; +}; + +const getNow = () => DateTime.now().setZone(TIMEZONE); + +const getDayStart = (input = getNow()) => { + const dt = ensureDateTime(input); + if (!dt || !dt.isValid) { + const fallback = getNow(); + return fallback.set({ + hour: BUSINESS_DAY_START_HOUR, + minute: 0, + second: 0, + millisecond: 0 + }); + } + + const sameDayStart = dt.set({ + hour: BUSINESS_DAY_START_HOUR, + minute: 0, + second: 0, + millisecond: 0 + }); + + return dt.hour < BUSINESS_DAY_START_HOUR + ? sameDayStart.minus({ days: 1 }) + : sameDayStart; +}; + +const getDayEnd = (input = getNow()) => { + return getDayStart(input).plus({ days: 1 }).minus({ milliseconds: 1 }); +}; + +const getWeekStart = (input = getNow()) => { + const dt = ensureDateTime(input); + if (!dt || !dt.isValid) { + return getDayStart(); + } + + const startOfWeek = dt.set({ weekday: WEEK_START_DAY }).startOf('day'); + const normalized = startOfWeek > dt ? startOfWeek.minus({ weeks: 1 }) : startOfWeek; + return normalized.set({ + hour: BUSINESS_DAY_START_HOUR, + minute: 0, + second: 0, + millisecond: 0 + }); +}; + +const getRangeForTimeRange = (timeRange = 'today', now = getNow()) => { + const current = ensureDateTime(now); + if (!current || !current.isValid) { + throw new Error('Invalid reference time for range calculation'); + } + + switch (timeRange) { + case 'today': { + return { + start: getDayStart(current), + end: getDayEnd(current) + }; + } + case 'yesterday': { + const target = current.minus({ days: 1 }); + return { + start: getDayStart(target), + end: getDayEnd(target) + }; + } + case 'twoDaysAgo': { + const target = current.minus({ days: 2 }); + return { + start: getDayStart(target), + end: getDayEnd(target) + }; + } + case 'thisWeek': { + return { + start: getWeekStart(current), + end: getDayEnd(current) + }; + } + case 'lastWeek': { + const lastWeek = current.minus({ weeks: 1 }); + const weekStart = getWeekStart(lastWeek); + const weekEnd = weekStart.plus({ days: 6 }); + return { + start: weekStart, + end: getDayEnd(weekEnd) + }; + } + case 'thisMonth': { + const dayStart = getDayStart(current); + const monthStart = dayStart.startOf('month').set({ hour: BUSINESS_DAY_START_HOUR }); + return { + start: monthStart, + end: getDayEnd(current) + }; + } + case 'lastMonth': { + const lastMonth = current.minus({ months: 1 }); + const monthStart = lastMonth + .startOf('month') + .set({ hour: BUSINESS_DAY_START_HOUR, minute: 0, second: 0, millisecond: 0 }); + const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 }); + return { + start: monthStart, + end: getDayEnd(monthEnd) + }; + } + case 'last7days': { + const dayStart = getDayStart(current); + return { + start: dayStart.minus({ days: 6 }), + end: getDayEnd(current) + }; + } + case 'last30days': { + const dayStart = getDayStart(current); + return { + start: dayStart.minus({ days: 29 }), + end: getDayEnd(current) + }; + } + case 'last90days': { + const dayStart = getDayStart(current); + return { + start: dayStart.minus({ days: 89 }), + end: getDayEnd(current) + }; + } + case 'previous7days': { + const currentPeriodStart = getDayStart(current).minus({ days: 6 }); + const previousEndDay = currentPeriodStart.minus({ days: 1 }); + const previousStartDay = previousEndDay.minus({ days: 6 }); + return { + start: getDayStart(previousStartDay), + end: getDayEnd(previousEndDay) + }; + } + case 'previous30days': { + const currentPeriodStart = getDayStart(current).minus({ days: 29 }); + const previousEndDay = currentPeriodStart.minus({ days: 1 }); + const previousStartDay = previousEndDay.minus({ days: 29 }); + return { + start: getDayStart(previousStartDay), + end: getDayEnd(previousEndDay) + }; + } + case 'previous90days': { + const currentPeriodStart = getDayStart(current).minus({ days: 89 }); + const previousEndDay = currentPeriodStart.minus({ days: 1 }); + const previousStartDay = previousEndDay.minus({ days: 89 }); + return { + start: getDayStart(previousStartDay), + end: getDayEnd(previousEndDay) + }; + } + default: + throw new Error(`Unknown time range: ${timeRange}`); + } +}; + +const toDatabaseSqlString = (dt) => { + const normalized = ensureDateTime(dt); + if (!normalized || !normalized.isValid) { + throw new Error('Invalid datetime provided for SQL conversion'); + } + const dbTime = normalized.setZone(DB_TIMEZONE, { keepLocalTime: true }); + return dbTime.toFormat(DB_DATETIME_FORMAT); +}; + +const formatBusinessDate = (input) => { + const dt = ensureDateTime(input); + if (!dt || !dt.isValid) return ''; + return dt.setZone(TIMEZONE).toFormat('LLL d, yyyy'); +}; + +const getTimeRangeLabel = (timeRange) => { + const labels = { + today: 'Today', + yesterday: 'Yesterday', + twoDaysAgo: 'Two Days Ago', + thisWeek: 'This Week', + lastWeek: 'Last Week', + thisMonth: 'This Month', + lastMonth: 'Last Month', + last7days: 'Last 7 Days', + last30days: 'Last 30 Days', + last90days: 'Last 90 Days', + previous7days: 'Previous 7 Days', + previous30days: 'Previous 30 Days', + previous90days: 'Previous 90 Days' + }; + + return labels[timeRange] || timeRange; +}; + +const getTimeRangeConditions = (timeRange, startDate, endDate) => { + if (timeRange === 'custom' && startDate && endDate) { + const start = ensureDateTime(startDate); + const end = ensureDateTime(endDate); + + if (!start || !start.isValid || !end || !end.isValid) { + throw new Error('Invalid custom date range provided'); + } + + return { + whereClause: 'date_placed >= ? AND date_placed <= ?', + params: [toDatabaseSqlString(start), toDatabaseSqlString(end)], + dateRange: { + start: start.toUTC().toISO(), + end: end.toUTC().toISO(), + label: `${formatBusinessDate(start)} - ${formatBusinessDate(end)}` + } + }; + } + + const normalizedRange = timeRange || 'today'; + const range = getRangeForTimeRange(normalizedRange); + + return { + whereClause: 'date_placed >= ? AND date_placed <= ?', + params: [toDatabaseSqlString(range.start), toDatabaseSqlString(range.end)], + dateRange: { + start: range.start.toUTC().toISO(), + end: range.end.toUTC().toISO(), + label: getTimeRangeLabel(normalizedRange) + } + }; +}; + +const getBusinessDayBounds = (timeRange) => { + const range = getRangeForTimeRange(timeRange); + return { + start: range.start.toJSDate(), + end: range.end.toJSDate() + }; +}; + +const parseBusinessDate = (mysqlDatetime) => { + if (!mysqlDatetime || mysqlDatetime === '0000-00-00 00:00:00') { + return null; + } + + const dt = DateTime.fromSQL(mysqlDatetime, { zone: DB_TIMEZONE }); + if (!dt.isValid) { + console.error('[timeUtils] Failed to parse MySQL datetime:', mysqlDatetime, dt.invalidExplanation); + return null; + } + + return dt.toUTC().toJSDate(); +}; + +const formatMySQLDate = (input) => { + if (!input) return null; + + const dt = ensureDateTime(input, { zone: 'utc' }); + if (!dt || !dt.isValid) return null; + + return dt.setZone(DB_TIMEZONE).toFormat(DB_DATETIME_FORMAT); +}; + +module.exports = { + getBusinessDayBounds, + getTimeRangeConditions, + formatBusinessDate, + getTimeRangeLabel, + parseBusinessDate, + formatMySQLDate, + // Expose helpers for tests or advanced consumers + _internal: { + getDayStart, + getDayEnd, + getWeekStart, + getRangeForTimeRange, + BUSINESS_DAY_START_HOUR + } +}; diff --git a/inventory-server/dashboard/aircall-server/.env.example b/inventory-server/dashboard/aircall-server/.env.example new file mode 100644 index 0000000..156f6a3 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/.env.example @@ -0,0 +1,21 @@ +# Server Configuration +NODE_ENV=development +AIRCALL_PORT=3002 +LOG_LEVEL=info + +# Aircall API Credentials +AIRCALL_API_ID=your_aircall_api_id +AIRCALL_API_TOKEN=your_aircall_api_token + +# Database Configuration +MONGODB_URI=mongodb://localhost:27017/dashboard +MONGODB_DB=dashboard +REDIS_URL=redis://localhost:6379 + +# Service Configuration +TIMEZONE=America/New_York +DAY_STARTS_AT=1 # Business day starts at 1 AM ET + +# Optional Settings +REDIS_TTL=300 # Cache TTL in seconds (5 minutes) +COLLECTION_NAME=aircall_daily_data \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/README.md b/inventory-server/dashboard/aircall-server/README.md new file mode 100644 index 0000000..8a42546 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/README.md @@ -0,0 +1,55 @@ +# Aircall Server + +A standalone server for handling Aircall metrics and data processing. + +## Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Set up environment variables: +```bash +cp .env.example .env +``` +Then edit `.env` with your configuration. + +Required environment variables: +- `AIRCALL_API_ID`: Your Aircall API ID +- `AIRCALL_API_TOKEN`: Your Aircall API Token +- `MONGODB_URI`: MongoDB connection string +- `REDIS_URL`: Redis connection string +- `AIRCALL_PORT`: Server port (default: 3002) + +## Running the Server + +### Development +```bash +npm run dev +``` + +### Production +Using PM2: +```bash +pm2 start ecosystem.config.js --env production +``` + +## API Endpoints + +### GET /api/aircall/metrics/:timeRange +Get Aircall metrics for a specific time range. + +Parameters: +- `timeRange`: One of ['today', 'yesterday', 'last7days', 'last30days', 'last90days'] + +### GET /api/aircall/health +Get server health status. + +## Architecture + +The server uses: +- Express.js for the API +- MongoDB for data storage +- Redis for caching +- Winston for logging \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/package-lock.json b/inventory-server/dashboard/aircall-server/package-lock.json new file mode 100644 index 0000000..91017cb --- /dev/null +++ b/inventory-server/dashboard/aircall-server/package-lock.json @@ -0,0 +1,1914 @@ +{ + "name": "aircall-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aircall-server", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "mongodb": "^6.3.0", + "redis": "^4.6.11", + "winston": "^3.11.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "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.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bson": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.1.tgz", + "integrity": "sha512-P92xmHDQjSKPLHqFxefqMxASNq/aWJMEZugpCjf+AF/pgcUpMMQCg7t7+ewko0/u8AapvF3luf/FoehddEK+sA==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "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": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.2.tgz", + "integrity": "sha512-0lk0PHFe/uz0vl527fG9CgdE9WdafjDbCXvBbs+LUv000TVt2Jjhqbs4Jwm8gz070w8xXyEAxrPOMullsxXeGg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "get-intrinsic": "^1.2.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "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/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz", + "integrity": "sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "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/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "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.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "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": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "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.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/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/math-intrinsics": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.0.0.tgz", + "integrity": "sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mongodb": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz", + "integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.1", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/inventory-server/dashboard/aircall-server/package.json b/inventory-server/dashboard/aircall-server/package.json new file mode 100644 index 0000000..7c3a8ab --- /dev/null +++ b/inventory-server/dashboard/aircall-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "aircall-server", + "version": "1.0.0", + "description": "Aircall metrics server", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "mongodb": "^6.3.0", + "redis": "^4.6.11", + "winston": "^3.11.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/server.js b/inventory-server/dashboard/aircall-server/server.js new file mode 100644 index 0000000..7c63a54 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/server.js @@ -0,0 +1,83 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRoutes } from './src/routes/index.js'; +import { aircallConfig } from './src/config/aircall.config.js'; +import { connectMongoDB } from './src/utils/db.js'; +import { createRedisClient } from './src/utils/redis.js'; +import { createLogger } from './src/utils/logger.js'; + +// Get directory name in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables from the correct path +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +// Validate required environment variables +const requiredEnvVars = ['AIRCALL_API_ID', 'AIRCALL_API_TOKEN', 'MONGODB_URI', 'REDIS_URL']; +const missingEnvVars = requiredEnvVars.filter(envVar => !process.env[envVar]); + +if (missingEnvVars.length > 0) { + console.error('Missing required environment variables:', missingEnvVars); + process.exit(1); +} + +const app = express(); +const port = process.env.AIRCALL_PORT || 3002; +const logger = createLogger('aircall-server'); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Connect to databases +let mongodb; +let redis; + +async function initializeServer() { + try { + // Connect to MongoDB + mongodb = await connectMongoDB(); + logger.info('Connected to MongoDB'); + + // Connect to Redis + redis = await createRedisClient(); + logger.info('Connected to Redis'); + + // Initialize configs with database connections + const configs = { + aircall: { + ...aircallConfig, + mongodb, + redis, + logger + } + }; + + // Initialize routes + const routes = createRoutes(configs, logger); + app.use('/api', routes); + + // Error handling middleware + app.use((err, req, res, next) => { + logger.error('Server error:', err); + res.status(500).json({ + error: 'Internal server error', + message: err.message + }); + }); + + // Start server + app.listen(port, () => { + logger.info(`Aircall server listening on port ${port}`); + }); + } catch (error) { + logger.error('Failed to initialize server:', error); + process.exit(1); + } +} + +initializeServer(); \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/config/aircall.config.js b/inventory-server/dashboard/aircall-server/src/config/aircall.config.js new file mode 100644 index 0000000..ad455c6 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/config/aircall.config.js @@ -0,0 +1,15 @@ +export const aircallConfig = { + serviceName: 'aircall', + apiId: process.env.AIRCALL_API_ID, + apiToken: process.env.AIRCALL_API_TOKEN, + timezone: 'America/New_York', + dayStartsAt: 1, + storeHistory: true, + collection: 'aircall_daily_data', + redisTTL: 300, // 5 minutes cache for current day + endpoints: { + metrics: { + ttl: 300 + } + } +}; \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/routes/aircall.routes.js b/inventory-server/dashboard/aircall-server/src/routes/aircall.routes.js new file mode 100644 index 0000000..06c1551 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/routes/aircall.routes.js @@ -0,0 +1,57 @@ +import express from 'express'; +import { AircallService } from '../services/aircall/AircallService.js'; + +export const createAircallRoutes = (config, logger) => { + const router = express.Router(); + const aircallService = new AircallService(config); + + router.get('/metrics/:timeRange?', async (req, res) => { + try { + const { timeRange = 'today' } = req.params; + const allowedRanges = ['today', 'yesterday', 'last7days', 'last30days', 'last90days']; + + if (!allowedRanges.includes(timeRange)) { + return res.status(400).json({ + error: 'Invalid time range', + allowedRanges + }); + } + + const metrics = await aircallService.getMetrics(timeRange); + + res.json({ + ...metrics, + _meta: { + timeRange, + generatedAt: new Date().toISOString(), + dataPoints: metrics.daily_data?.length || 0 + } + }); + } catch (error) { + logger.error('Error fetching Aircall metrics:', error); + res.status(500).json({ + error: 'Failed to fetch Aircall metrics', + message: error.message + }); + } + }); + + // Health check endpoint + router.get('/health', (req, res) => { + const mongoConnected = !!aircallService.mongodb?.db; + const redisConnected = !!aircallService.redis?.isOpen; + + const health = { + status: mongoConnected && redisConnected ? 'ok' : 'degraded', + service: 'aircall', + timestamp: new Date().toISOString(), + connections: { + mongodb: mongoConnected, + redis: redisConnected + } + }; + res.json(health); + }); + + return router; +}; \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/routes/index.js b/inventory-server/dashboard/aircall-server/src/routes/index.js new file mode 100644 index 0000000..77c0e19 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/routes/index.js @@ -0,0 +1,32 @@ +import express from 'express'; +import { createAircallRoutes } from './aircall.routes.js'; + +export const createRoutes = (configs, logger) => { + const router = express.Router(); + + // Mount Aircall routes + router.use('/aircall', createAircallRoutes(configs.aircall, logger)); + + // Health check endpoint + router.get('/health', (req, res) => { + const services = req.services || {}; + res.status(200).json({ + status: 'ok', + timestamp: new Date(), + services: { + redis: services.redis?.isReady || false, + mongodb: services.mongo?.readyState === 1 || false + } + }); + }); + + // Catch-all 404 handler + router.use('*', (req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Route ${req.originalUrl} not found` + }); + }); + + return router; +}; \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/services/aircall/AircallDataManager.js b/inventory-server/dashboard/aircall-server/src/services/aircall/AircallDataManager.js new file mode 100644 index 0000000..8aa92f4 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/services/aircall/AircallDataManager.js @@ -0,0 +1,298 @@ +import { DataManager } from "../base/DataManager.js"; + +export class AircallDataManager extends DataManager { + constructor(mongodb, redis, timeManager) { + const options = { + collection: "aircall_daily_data", + redisTTL: 300 // 5 minutes cache + }; + super(mongodb, redis, timeManager, options); + this.options = options; + } + + ensureDate(d) { + if (d instanceof Date) return d; + if (typeof d === 'string') return new Date(d); + if (typeof d === 'number') return new Date(d); + console.error('Invalid date value:', d); + return new Date(); // fallback to current date + } + + async storeHistoricalPeriod(start, end, calls) { + if (!this.mongodb) return; + + try { + if (!Array.isArray(calls)) { + console.error("Invalid calls data:", calls); + return; + } + + // Group calls by true day boundaries using TimeManager + const dailyCallsMap = new Map(); + + calls.forEach((call) => { + try { + const timestamp = call.started_at * 1000; // Convert to milliseconds + const callDate = this.ensureDate(timestamp); + const dayBounds = this.timeManager.getDayBounds(callDate); + const dayKey = dayBounds.start.toISOString(); + + if (!dailyCallsMap.has(dayKey)) { + dailyCallsMap.set(dayKey, { + date: dayBounds.start, + calls: [], + }); + } + dailyCallsMap.get(dayKey).calls.push(call); + } catch (err) { + console.error('Error processing call:', err, call); + } + }); + + // Iterate over each day in the period using day boundaries + const dates = []; + let currentDate = this.ensureDate(start); + const endDate = this.ensureDate(end); + + while (currentDate < endDate) { + const dayBounds = this.timeManager.getDayBounds(currentDate); + dates.push(dayBounds.start); + currentDate.setUTCDate(currentDate.getUTCDate() + 1); + } + + for (const date of dates) { + try { + const dateKey = date.toISOString(); + const dayData = dailyCallsMap.get(dateKey); + const dayCalls = dayData ? dayData.calls : []; + + // Process calls for this day using the same processing logic + const metrics = this.processCallData(dayCalls); + + // Insert a daily_data record for this day + metrics.daily_data = [ + { + date: date.toISOString().split("T")[0], + inbound: metrics.by_direction.inbound, + outbound: metrics.by_direction.outbound, + }, + ]; + + // Store this day's processed data as historical + await this.storeHistoricalDay(date, metrics); + } catch (err) { + console.error('Error processing date:', err, date); + } + } + + } catch (error) { + console.error("Error storing historical period:", error, error.stack); + throw error; + } + } + + processCallData(calls) { + // If calls is already processed (has total, by_direction, etc.), return it + if (calls && calls.total !== undefined) { + console.log('Data already processed:', { + total: calls.total, + by_direction: calls.by_direction + }); + // Return a clean copy of the processed data + return { + total: calls.total, + by_direction: calls.by_direction, + by_status: calls.by_status, + by_missed_reason: calls.by_missed_reason, + by_hour: calls.by_hour, + by_users: calls.by_users, + daily_data: calls.daily_data, + duration_distribution: calls.duration_distribution, + average_duration: calls.average_duration + }; + } + + console.log('Processing raw calls:', { + count: calls.length, + sample: calls.length > 0 ? { + id: calls[0].id, + direction: calls[0].direction, + status: calls[0].status + } : null + }); + + // Process raw calls + const metrics = { + total: calls.length, + by_direction: { inbound: 0, outbound: 0 }, + by_status: { answered: 0, missed: 0 }, + by_missed_reason: {}, + by_hour: Array(24).fill(0), + by_users: {}, + daily_data: [], + duration_distribution: [ + { range: "0-1m", count: 0 }, + { range: "1-5m", count: 0 }, + { range: "5-15m", count: 0 }, + { range: "15-30m", count: 0 }, + { range: "30m+", count: 0 }, + ], + average_duration: 0, + total_duration: 0, + }; + + // Group calls by date for daily data + const dailyCallsMap = new Map(); + + calls.forEach((call) => { + try { + // Direction metrics + metrics.by_direction[call.direction]++; + + // Get call date and hour using TimeManager + const timestamp = call.started_at * 1000; // Convert to milliseconds + const callDate = this.ensureDate(timestamp); + const dayBounds = this.timeManager.getDayBounds(callDate); + const dayKey = dayBounds.start.toISOString().split("T")[0]; + const hour = callDate.getHours(); + metrics.by_hour[hour]++; + + // Status and duration metrics + if (call.answered_at) { + metrics.by_status.answered++; + const duration = call.ended_at - call.answered_at; + metrics.total_duration += duration; + + // Duration distribution + if (duration <= 60) { + metrics.duration_distribution[0].count++; + } else if (duration <= 300) { + metrics.duration_distribution[1].count++; + } else if (duration <= 900) { + metrics.duration_distribution[2].count++; + } else if (duration <= 1800) { + metrics.duration_distribution[3].count++; + } else { + metrics.duration_distribution[4].count++; + } + + // Track user performance + if (call.user) { + const userId = call.user.id; + if (!metrics.by_users[userId]) { + metrics.by_users[userId] = { + id: userId, + name: call.user.name, + total: 0, + answered: 0, + missed: 0, + total_duration: 0, + average_duration: 0, + }; + } + metrics.by_users[userId].total++; + metrics.by_users[userId].answered++; + metrics.by_users[userId].total_duration += duration; + } + } else { + metrics.by_status.missed++; + if (call.missed_call_reason) { + metrics.by_missed_reason[call.missed_call_reason] = + (metrics.by_missed_reason[call.missed_call_reason] || 0) + 1; + } + + // Track missed calls by user + if (call.user) { + const userId = call.user.id; + if (!metrics.by_users[userId]) { + metrics.by_users[userId] = { + id: userId, + name: call.user.name, + total: 0, + answered: 0, + missed: 0, + total_duration: 0, + average_duration: 0, + }; + } + metrics.by_users[userId].total++; + metrics.by_users[userId].missed++; + } + } + + // Group by date for daily data + if (!dailyCallsMap.has(dayKey)) { + dailyCallsMap.set(dayKey, { date: dayKey, inbound: 0, outbound: 0 }); + } + dailyCallsMap.get(dayKey)[call.direction]++; + } catch (err) { + console.error('Error processing call:', err, call); + } + }); + + // Calculate average durations for users + Object.values(metrics.by_users).forEach((user) => { + if (user.answered > 0) { + user.average_duration = Math.round(user.total_duration / user.answered); + } + }); + + // Calculate global average duration + if (metrics.by_status.answered > 0) { + metrics.average_duration = Math.round( + metrics.total_duration / metrics.by_status.answered + ); + } + + // Convert daily data map to sorted array + metrics.daily_data = Array.from(dailyCallsMap.values()).sort((a, b) => + a.date.localeCompare(b.date) + ); + + delete metrics.total_duration; + + console.log('Processed metrics:', { + total: metrics.total, + by_direction: metrics.by_direction, + by_status: metrics.by_status, + daily_data_count: metrics.daily_data.length + }); + + return metrics; + } + + async storeHistoricalDay(date, data) { + if (!this.mongodb) return; + + try { + const collection = this.mongodb.collection(this.options.collection); + const dayBounds = this.timeManager.getDayBounds(this.ensureDate(date)); + + // Ensure consistent data structure with metrics nested in data field + const document = { + date: dayBounds.start, + data: { + total: data.total, + by_direction: data.by_direction, + by_status: data.by_status, + by_missed_reason: data.by_missed_reason, + by_hour: data.by_hour, + by_users: data.by_users, + daily_data: data.daily_data, + duration_distribution: data.duration_distribution, + average_duration: data.average_duration + }, + updatedAt: new Date() + }; + + await collection.updateOne( + { date: dayBounds.start }, + { $set: document }, + { upsert: true } + ); + } catch (error) { + console.error("Error storing historical day:", error); + throw error; + } + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/services/aircall/AircallService.js b/inventory-server/dashboard/aircall-server/src/services/aircall/AircallService.js new file mode 100644 index 0000000..92c7173 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/services/aircall/AircallService.js @@ -0,0 +1,138 @@ +import axios from "axios"; +import { Buffer } from "buffer"; +import { BaseService } from "../base/BaseService.js"; +import { AircallDataManager } from "./AircallDataManager.js"; + +export class AircallService extends BaseService { + constructor(config) { + super(config); + this.baseUrl = "https://api.aircall.io/v1"; + console.log('Initializing Aircall service with credentials:', { + apiId: config.apiId ? 'present' : 'missing', + apiToken: config.apiToken ? 'present' : 'missing' + }); + this.auth = Buffer.from(`${config.apiId}:${config.apiToken}`).toString( + "base64" + ); + this.dataManager = new AircallDataManager( + this.mongodb, + this.redis, + this.timeManager + ); + + if (!config.apiId || !config.apiToken) { + throw new Error("Aircall API credentials are required"); + } + } + + async getMetrics(timeRange) { + const dateRange = await this.timeManager.getDateRange(timeRange); + console.log('Fetching metrics for date range:', { + start: dateRange.start.toISOString(), + end: dateRange.end.toISOString() + }); + + return this.dataManager.getData(dateRange, async (range) => { + const calls = await this.fetchAllCalls(range.start, range.end); + console.log('Fetched calls:', { + count: calls.length, + sample: calls.length > 0 ? calls[0] : null + }); + return calls; + }); + } + + async fetchAllCalls(start, end) { + try { + let allCalls = []; + let currentPage = 1; + let hasMore = true; + let totalPages = null; + + while (hasMore) { + const response = await this.makeRequest("/calls", { + from: Math.floor(start.getTime() / 1000), + to: Math.floor(end.getTime() / 1000), + order: "asc", + page: currentPage, + per_page: 50, + }); + + console.log('API Response:', { + page: currentPage, + totalPages: response.meta.total_pages, + callsCount: response.calls?.length, + params: { + from: Math.floor(start.getTime() / 1000), + to: Math.floor(end.getTime() / 1000) + } + }); + + if (!response.calls) { + throw new Error("Invalid API response format"); + } + + allCalls = [...allCalls, ...response.calls]; + hasMore = response.meta.next_page_link !== null; + totalPages = response.meta.total_pages; + currentPage++; + + if (hasMore) { + // Rate limiting pause + await new Promise((resolve) => setTimeout(resolve, 1)); + } + } + + return allCalls; + } catch (error) { + console.error("Error fetching all calls:", error); + throw error; + } + } + + async makeRequest(endpoint, params = {}) { + try { + console.log('Making API request:', { + endpoint, + params + }); + const response = await axios.get(`${this.baseUrl}${endpoint}`, { + headers: { + Authorization: `Basic ${this.auth}`, + "Content-Type": "application/json", + }, + params, + }); + return response.data; + } catch (error) { + if (error.response?.status === 429) { + console.log("Rate limit reached, waiting before retry..."); + await new Promise((resolve) => setTimeout(resolve, 5000)); + return this.makeRequest(endpoint, params); + } + + this.handleApiError(error, `Error making request to ${endpoint}`); + } + } + + validateApiResponse(response, context = "") { + if (!response || typeof response !== "object") { + throw new Error(`${context}: Invalid API response format`); + } + + if (response.error) { + throw new Error(`${context}: ${response.error}`); + } + + return true; + } + + getPaginationInfo(meta) { + return { + currentPage: meta.current_page, + totalPages: meta.total_pages, + hasNextPage: meta.next_page_link !== null, + totalRecords: meta.total, + }; + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/services/base/BaseService.js b/inventory-server/dashboard/aircall-server/src/services/base/BaseService.js new file mode 100644 index 0000000..55ed888 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/services/base/BaseService.js @@ -0,0 +1,32 @@ +import { createTimeManager } from '../../utils/timeUtils.js'; + +export class BaseService { + constructor(config) { + this.config = config; + this.mongodb = config.mongodb; + this.redis = config.redis; + this.logger = config.logger; + this.timeManager = createTimeManager(config.timezone, config.dayStartsAt); + } + + handleApiError(error, context = '') { + this.logger.error(`API Error ${context}:`, { + message: error.message, + status: error.response?.status, + data: error.response?.data, + }); + + if (error.response) { + const status = error.response.status; + const message = error.response.data?.message || error.response.statusText; + + if (status === 429) { + throw new Error('API rate limit exceeded. Please try again later.'); + } + + throw new Error(`API error (${status}): ${message}`); + } + + throw error; + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/services/base/DataManager.js b/inventory-server/dashboard/aircall-server/src/services/base/DataManager.js new file mode 100644 index 0000000..0dfc125 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/services/base/DataManager.js @@ -0,0 +1,320 @@ +export class DataManager { + constructor(mongodb, redis, timeManager, options) { + this.mongodb = mongodb; + this.redis = redis; + this.timeManager = timeManager; + this.options = options || {}; + } + + ensureDate(d) { + if (d instanceof Date) return d; + if (typeof d === 'string') return new Date(d); + if (typeof d === 'number') return new Date(d); + if (d && d.date) return new Date(d.date); // Handle MongoDB records + console.error('Invalid date value:', d); + return new Date(); // fallback to current date + } + + async getData(dateRange, fetchFn) { + try { + // Get historical data from MongoDB + const historicalData = await this.getHistoricalDays(dateRange.start, dateRange.end); + + // Find any missing date ranges + const missingRanges = this.findMissingDateRanges(dateRange.start, dateRange.end, historicalData); + + // Fetch missing data + for (const range of missingRanges) { + const data = await fetchFn(range); + await this.storeHistoricalPeriod(range.start, range.end, data); + } + + // Get updated historical data + const updatedData = await this.getHistoricalDays(dateRange.start, dateRange.end); + + // Handle both nested and flat data structures + if (updatedData && updatedData.length > 0) { + // Process each record and combine them + const processedData = updatedData.map(record => { + if (record.data) { + return record.data; + } + if (record.total !== undefined) { + return { + total: record.total, + by_direction: record.by_direction, + by_status: record.by_status, + by_missed_reason: record.by_missed_reason, + by_hour: record.by_hour, + by_users: record.by_users, + daily_data: record.daily_data, + duration_distribution: record.duration_distribution, + average_duration: record.average_duration + }; + } + return null; + }).filter(Boolean); + + // Combine the data + if (processedData.length > 0) { + return this.combineMetrics(processedData); + } + } + + // Otherwise process as raw call data + return this.processCallData(updatedData); + } catch (error) { + console.error('Error in getData:', error); + throw error; + } + } + + findMissingDateRanges(start, end, existingDates) { + const missingRanges = []; + const existingDatesSet = new Set( + existingDates.map((d) => { + // Handle both nested and flat data structures + const date = d.date ? d.date : d; + return this.ensureDate(date).toISOString().split("T")[0]; + }) + ); + + let current = new Date(start); + const endDate = new Date(end); + + while (current < endDate) { + const dayBounds = this.timeManager.getDayBounds(current); + const dayKey = dayBounds.start.toISOString().split("T")[0]; + + if (!existingDatesSet.has(dayKey)) { + // Found a missing day + const missingStart = new Date(dayBounds.start); + const missingEnd = new Date(dayBounds.end); + + missingRanges.push({ + start: missingStart, + end: missingEnd, + }); + } + + // Move to the next day using timeManager to ensure proper business day boundaries + current = new Date(dayBounds.end.getTime() + 1); + } + + return missingRanges; + } + + async getCurrentDay(fetchFn) { + const now = new Date(); + const todayBounds = this.timeManager.getDayBounds(now); + const todayKey = this.timeManager.formatDate(todayBounds.start); + const cacheKey = `${this.options.collection}:current_day:${todayKey}`; + + try { + // Check cache first + if (this.redis?.isOpen) { + const cached = await this.redis.get(cacheKey); + if (cached) { + const parsedCache = JSON.parse(cached); + if (parsedCache.total !== undefined) { + // Use timeManager to check if the cached data is for today + const cachedDate = new Date(parsedCache.daily_data[0].date); + const isToday = this.timeManager.isToday(cachedDate); + + if (isToday) { + return parsedCache; + } + } + } + } + + // Get safe end time that's never in the future + const safeEnd = this.timeManager.getCurrentBusinessDayEnd(); + + // Fetch and process current day data with safe end time + const data = await fetchFn({ + start: todayBounds.start, + end: safeEnd + }); + + if (!data) { + return null; + } + + // Cache the data with a shorter TTL for today's data + if (this.redis?.isOpen) { + const ttl = Math.min( + this.options.redisTTL, + 60 * 5 // 5 minutes max for today's data + ); + await this.redis.set(cacheKey, JSON.stringify(data), { + EX: ttl, + }); + } + + return data; + } catch (error) { + console.error('Error in getCurrentDay:', error); + throw error; + } + } + + getDayCount(start, end) { + // Calculate full days between dates using timeManager + const startDay = this.timeManager.getDayBounds(start); + const endDay = this.timeManager.getDayBounds(end); + return Math.ceil((endDay.end - startDay.start) / (24 * 60 * 60 * 1000)); + } + + async fetchMissingDays(start, end, existingData, fetchFn) { + const existingDates = new Set( + existingData.map((d) => this.timeManager.formatDate(d.date)) + ); + const missingData = []; + + let currentDate = new Date(start); + while (currentDate < end) { + const dayBounds = this.timeManager.getDayBounds(currentDate); + const dateString = this.timeManager.formatDate(dayBounds.start); + + if (!existingDates.has(dateString)) { + const data = await fetchFn({ + start: dayBounds.start, + end: dayBounds.end, + }); + + await this.storeHistoricalDay(dayBounds.start, data); + missingData.push(data); + } + + // Move to next day using timeManager to ensure proper business day boundaries + currentDate = new Date(dayBounds.end.getTime() + 1); + } + + return missingData; + } + + async getHistoricalDays(start, end) { + try { + if (!this.mongodb) return []; + + const collection = this.mongodb.collection(this.options.collection); + const startDay = this.timeManager.getDayBounds(start); + const endDay = this.timeManager.getDayBounds(end); + + const records = await collection + .find({ + date: { + $gte: startDay.start, + $lt: endDay.start, + }, + }) + .sort({ date: 1 }) + .toArray(); + + return records; + } catch (error) { + console.error('Error getting historical days:', error); + return []; + } + } + + combineMetrics(metricsArray) { + if (!metricsArray || metricsArray.length === 0) return null; + if (metricsArray.length === 1) return metricsArray[0]; + + const combined = { + total: 0, + by_direction: { inbound: 0, outbound: 0 }, + by_status: { answered: 0, missed: 0 }, + by_missed_reason: {}, + by_hour: Array(24).fill(0), + by_users: {}, + daily_data: [], + duration_distribution: [ + { range: '0-1m', count: 0 }, + { range: '1-5m', count: 0 }, + { range: '5-15m', count: 0 }, + { range: '15-30m', count: 0 }, + { range: '30m+', count: 0 } + ], + average_duration: 0 + }; + + let totalAnswered = 0; + let totalDuration = 0; + + metricsArray.forEach(metrics => { + // Sum basic metrics + combined.total += metrics.total; + combined.by_direction.inbound += metrics.by_direction.inbound; + combined.by_direction.outbound += metrics.by_direction.outbound; + combined.by_status.answered += metrics.by_status.answered; + combined.by_status.missed += metrics.by_status.missed; + + // Combine missed reasons + Object.entries(metrics.by_missed_reason).forEach(([reason, count]) => { + combined.by_missed_reason[reason] = (combined.by_missed_reason[reason] || 0) + count; + }); + + // Sum hourly data + metrics.by_hour.forEach((count, hour) => { + combined.by_hour[hour] += count; + }); + + // Combine user data + Object.entries(metrics.by_users).forEach(([userId, userData]) => { + if (!combined.by_users[userId]) { + combined.by_users[userId] = { + id: userData.id, + name: userData.name, + total: 0, + answered: 0, + missed: 0, + total_duration: 0, + average_duration: 0 + }; + } + combined.by_users[userId].total += userData.total; + combined.by_users[userId].answered += userData.answered; + combined.by_users[userId].missed += userData.missed; + combined.by_users[userId].total_duration += userData.total_duration || 0; + }); + + // Combine duration distribution + metrics.duration_distribution.forEach((dist, index) => { + combined.duration_distribution[index].count += dist.count; + }); + + // Accumulate for average duration calculation + if (metrics.average_duration && metrics.by_status.answered) { + totalDuration += metrics.average_duration * metrics.by_status.answered; + totalAnswered += metrics.by_status.answered; + } + + // Merge daily data + if (metrics.daily_data) { + combined.daily_data.push(...metrics.daily_data); + } + }); + + // Calculate final average duration + if (totalAnswered > 0) { + combined.average_duration = Math.round(totalDuration / totalAnswered); + } + + // Calculate user averages + Object.values(combined.by_users).forEach(user => { + if (user.answered > 0) { + user.average_duration = Math.round(user.total_duration / user.answered); + } + }); + + // Sort and deduplicate daily data + combined.daily_data = Array.from( + new Map(combined.daily_data.map(item => [item.date, item])).values() + ).sort((a, b) => a.date.localeCompare(b.date)); + + return combined; + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/utils/db.js b/inventory-server/dashboard/aircall-server/src/utils/db.js new file mode 100644 index 0000000..c6b2a01 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/utils/db.js @@ -0,0 +1,15 @@ +import { MongoClient } from 'mongodb'; + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/dashboard'; +const DB_NAME = process.env.MONGODB_DB || 'dashboard'; + +export async function connectMongoDB() { + try { + const client = await MongoClient.connect(MONGODB_URI); + console.log('Connected to MongoDB'); + return client.db(DB_NAME); + } catch (error) { + console.error('MongoDB connection error:', error); + throw error; + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/utils/logger.js b/inventory-server/dashboard/aircall-server/src/utils/logger.js new file mode 100644 index 0000000..80a200f --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/utils/logger.js @@ -0,0 +1,37 @@ +import winston from 'winston'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export function createLogger(service) { + // Create logs directory relative to the project root (two levels up from utils) + const logsDir = path.join(__dirname, '../../logs'); + + return winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + defaultMeta: { service }, + transports: [ + // Write all logs to console + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }), + // Write all logs to service-specific files + new winston.transports.File({ + filename: path.join(logsDir, `${service}-error.log`), + level: 'error' + }), + new winston.transports.File({ + filename: path.join(logsDir, `${service}-combined.log`) + }) + ] + }); +} \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/utils/redis.js b/inventory-server/dashboard/aircall-server/src/utils/redis.js new file mode 100644 index 0000000..ee51cce --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/utils/redis.js @@ -0,0 +1,23 @@ +import { createClient } from 'redis'; + +const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; + +export async function createRedisClient() { + try { + const client = createClient({ + url: REDIS_URL + }); + + await client.connect(); + console.log('Connected to Redis'); + + client.on('error', (err) => { + console.error('Redis error:', err); + }); + + return client; + } catch (error) { + console.error('Redis connection error:', error); + throw error; + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/aircall-server/src/utils/timeUtils.js b/inventory-server/dashboard/aircall-server/src/utils/timeUtils.js new file mode 100644 index 0000000..ac53e06 --- /dev/null +++ b/inventory-server/dashboard/aircall-server/src/utils/timeUtils.js @@ -0,0 +1,262 @@ +class TimeManager { + static ALLOWED_RANGES = ['today', 'yesterday', 'last2days', 'last7days', 'last30days', 'last90days', + 'previous7days', 'previous30days', 'previous90days']; + + constructor(timezone = 'America/New_York', dayStartsAt = 1) { + this.timezone = timezone; + this.dayStartsAt = dayStartsAt; + } + + getDayBounds(date) { + try { + const now = new Date(); + const targetDate = new Date(date); + + // For today + if ( + targetDate.getUTCFullYear() === now.getUTCFullYear() && + targetDate.getUTCMonth() === now.getUTCMonth() && + targetDate.getUTCDate() === now.getUTCDate() + ) { + // If current time is before day start (1 AM ET / 6 AM UTC), + // use previous day's start until now + const todayStart = new Date(Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + this.dayStartsAt + 5, + 0, + 0, + 0 + )); + + if (now < todayStart) { + const yesterdayStart = new Date(todayStart); + yesterdayStart.setUTCDate(yesterdayStart.getUTCDate() - 1); + return { start: yesterdayStart, end: now }; + } + + return { start: todayStart, end: now }; + } + + // For past days, use full 24-hour period + const normalizedDate = new Date(Date.UTC( + targetDate.getUTCFullYear(), + targetDate.getUTCMonth(), + targetDate.getUTCDate() + )); + + const dayStart = new Date(normalizedDate); + dayStart.setUTCHours(this.dayStartsAt + 5, 0, 0, 0); + + const dayEnd = new Date(dayStart); + dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); + + return { start: dayStart, end: dayEnd }; + } catch (error) { + console.error('Error in getDayBounds:', error); + throw new Error(`Failed to calculate day bounds: ${error.message}`); + } + } + + getDateRange(period) { + try { + const now = new Date(); + const todayBounds = this.getDayBounds(now); + const end = new Date(); + + switch (period) { + case 'today': + return { + start: todayBounds.start, + end + }; + case 'yesterday': { + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + return this.getDayBounds(yesterday); + } + case 'last2days': { + const twoDaysAgo = new Date(now); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + return this.getDayBounds(twoDaysAgo); + } + case 'last7days': { + const start = new Date(now); + start.setDate(start.getDate() - 6); + return { + start: this.getDayBounds(start).start, + end + }; + } + case 'previous7days': { + const end = new Date(now); + end.setDate(end.getDate() - 7); + const start = new Date(end); + start.setDate(start.getDate() - 6); + return { + start: this.getDayBounds(start).start, + end: this.getDayBounds(end).end + }; + } + case 'last30days': { + const start = new Date(now); + start.setDate(start.getDate() - 29); + return { + start: this.getDayBounds(start).start, + end + }; + } + case 'previous30days': { + const end = new Date(now); + end.setDate(end.getDate() - 30); + const start = new Date(end); + start.setDate(start.getDate() - 29); + return { + start: this.getDayBounds(start).start, + end: this.getDayBounds(end).end + }; + } + case 'last90days': { + const start = new Date(now); + start.setDate(start.getDate() - 89); + return { + start: this.getDayBounds(start).start, + end + }; + } + case 'previous90days': { + const end = new Date(now); + end.setDate(end.getDate() - 90); + const start = new Date(end); + start.setDate(start.getDate() - 89); + return { + start: this.getDayBounds(start).start, + end: this.getDayBounds(end).end + }; + } + default: + throw new Error(`Unsupported time period: ${period}`); + } + } catch (error) { + console.error('Error in getDateRange:', error); + throw error; + } + } + + getPreviousPeriod(period) { + try { + const now = new Date(); + + switch (period) { + case 'today': + return 'yesterday'; + case 'yesterday': { + // Return bounds for 2 days ago + const twoDaysAgo = new Date(now); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + return this.getDayBounds(twoDaysAgo); + } + case 'last7days': { + // Return bounds for previous 7 days + const end = new Date(now); + end.setDate(end.getDate() - 7); + const start = new Date(end); + start.setDate(start.getDate() - 7); + return { + start: this.getDayBounds(start).start, + end: this.getDayBounds(end).end + }; + } + case 'last30days': { + const end = new Date(now); + end.setDate(end.getDate() - 30); + const start = new Date(end); + start.setDate(start.getDate() - 30); + return { + start: this.getDayBounds(start).start, + end: this.getDayBounds(end).end + }; + } + case 'last90days': { + const end = new Date(now); + end.setDate(end.getDate() - 90); + const start = new Date(end); + start.setDate(start.getDate() - 90); + return { + start: this.getDayBounds(start).start, + end: this.getDayBounds(end).end + }; + } + default: + throw new Error(`Unsupported time period: ${period}`); + } + } catch (error) { + console.error('Error in getPreviousPeriod:', error); + throw error; + } + } + + getCurrentBusinessDayEnd() { + try { + const now = new Date(); + const todayBounds = this.getDayBounds(now); + + // If current time is before day start (1 AM ET / 6 AM UTC), + // then we're still in yesterday's business day + const todayStart = new Date(Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + this.dayStartsAt + 5, + 0, + 0, + 0 + )); + + if (now < todayStart) { + const yesterdayBounds = this.getDayBounds(new Date(now.getTime() - 24 * 60 * 60 * 1000)); + return yesterdayBounds.end; + } + + // Return the earlier of current time or today's end + return now < todayBounds.end ? now : todayBounds.end; + } catch (error) { + console.error('Error in getCurrentBusinessDayEnd:', error); + return new Date(); + } + } + + isValidTimeRange(timeRange) { + return TimeManager.ALLOWED_RANGES.includes(timeRange); + } + + isToday(date) { + const now = new Date(); + const targetDate = new Date(date); + return ( + targetDate.getUTCFullYear() === now.getUTCFullYear() && + targetDate.getUTCMonth() === now.getUTCMonth() && + targetDate.getUTCDate() === now.getUTCDate() + ); + } + + formatDate(date) { + try { + return date.toLocaleString('en-US', { + timeZone: this.timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } catch (error) { + console.error('Error formatting date:', error); + return date.toISOString(); + } + } +} + +export const createTimeManager = (timezone, dayStartsAt) => new TimeManager(timezone, dayStartsAt); \ No newline at end of file diff --git a/inventory-server/dashboard/auth-server/.env.example b/inventory-server/dashboard/auth-server/.env.example new file mode 100644 index 0000000..fa8d941 --- /dev/null +++ b/inventory-server/dashboard/auth-server/.env.example @@ -0,0 +1,10 @@ +# Server Configuration +NODE_ENV=development +PORT=3003 + +# Authentication +JWT_SECRET=your-secret-key-here +DASHBOARD_PASSWORD=your-dashboard-password-here + +# Cookie Settings +COOKIE_DOMAIN=localhost # In production: .kent.pw \ No newline at end of file diff --git a/inventory-server/dashboard/auth-server/index.js b/inventory-server/dashboard/auth-server/index.js new file mode 100644 index 0000000..5e81177 --- /dev/null +++ b/inventory-server/dashboard/auth-server/index.js @@ -0,0 +1,203 @@ +// auth-server/index.js +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '.env') }); +const express = require('express'); +const cors = require('cors'); +const cookieParser = require('cookie-parser'); +const jwt = require('jsonwebtoken'); + +// Debug environment variables +console.log('Environment variables loaded from:', path.join(__dirname, '.env')); +console.log('Current directory:', __dirname); +console.log('Available env vars:', Object.keys(process.env)); + +const app = express(); +const PORT = process.env.PORT || 3003; +const JWT_SECRET = process.env.JWT_SECRET; +const DASHBOARD_PASSWORD = process.env.DASHBOARD_PASSWORD; + +// Validate required environment variables +if (!JWT_SECRET || !DASHBOARD_PASSWORD) { + console.error('Missing required environment variables:'); + if (!JWT_SECRET) console.error('- JWT_SECRET'); + if (!DASHBOARD_PASSWORD) console.error('- DASHBOARD_PASSWORD'); + process.exit(1); +} + +// Middleware +app.use(express.json()); +app.use(cookieParser()); + +// Configure CORS +const corsOptions = { + origin: function(origin, callback) { + const allowedOrigins = [ + 'http://localhost:3000', + 'https://dashboard.kent.pw' + ]; + + console.log('CORS check for origin:', origin); + + // Allow local network IPs (192.168.1.xxx) + if (origin && origin.match(/^http:\/\/192\.168\.1\.\d{1,3}(:\d+)?$/)) { + callback(null, true); + return; + } + + // Check if origin is in allowed list + if (!origin || allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'Accept'], + exposedHeaders: ['Set-Cookie'] +}; + +app.use(cors(corsOptions)); +app.options('*', cors(corsOptions)); + +// Debug logging +app.use((req, res, next) => { + console.log(`${new Date().toISOString()} ${req.method} ${req.url}`); + console.log('Headers:', req.headers); + console.log('Cookies:', req.cookies); + next(); +}); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString() + }); +}); + +// Auth endpoints +app.post('/login', (req, res) => { + console.log('Login attempt received'); + console.log('Request body:', req.body); + console.log('Origin:', req.headers.origin); + + const { password } = req.body; + + if (!password) { + console.log('No password provided'); + return res.status(400).json({ + success: false, + message: 'Password is required' + }); + } + + console.log('Comparing passwords...'); + console.log('Provided password length:', password.length); + console.log('Expected password length:', DASHBOARD_PASSWORD.length); + + if (password === DASHBOARD_PASSWORD) { + console.log('Password matched'); + const token = jwt.sign({ authorized: true }, JWT_SECRET, { + expiresIn: '24h' + }); + + // Determine if request is from local network + const isLocalNetwork = req.headers.origin?.includes('192.168.1.') || req.headers.origin?.includes('localhost'); + + const cookieOptions = { + httpOnly: true, + secure: !isLocalNetwork, // Only use secure for non-local requests + sameSite: isLocalNetwork ? 'lax' : 'none', + path: '/', + maxAge: 24 * 60 * 60 * 1000 // 24 hours + }; + + // Only set domain for production + if (!isLocalNetwork) { + cookieOptions.domain = '.kent.pw'; + } + + console.log('Setting cookie with options:', cookieOptions); + res.cookie('token', token, cookieOptions); + + console.log('Response headers:', res.getHeaders()); + res.json({ + success: true, + debug: { + origin: req.headers.origin, + cookieOptions + } + }); + } else { + console.log('Password mismatch'); + res.status(401).json({ + success: false, + message: 'Invalid password' + }); + } +}); + +// Modify the check endpoint to log more info +app.get('/check', (req, res) => { + console.log('Auth check received'); + console.log('All cookies:', req.cookies); + console.log('Headers:', req.headers); + + const token = req.cookies.token; + + if (!token) { + console.log('No token found in cookies'); + return res.status(401).json({ + authenticated: false, + error: 'no_token' + }); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + console.log('Token verified successfully:', decoded); + res.json({ authenticated: true }); + } catch (err) { + console.log('Token verification failed:', err.message); + res.status(401).json({ + authenticated: false, + error: 'invalid_token', + message: err.message + }); + } +}); + +app.post('/logout', (req, res) => { + const isLocalNetwork = req.headers.origin?.includes('192.168.1.') || req.headers.origin?.includes('localhost'); + const cookieOptions = { + httpOnly: true, + secure: !isLocalNetwork, + sameSite: isLocalNetwork ? 'lax' : 'none', + path: '/', + domain: isLocalNetwork ? undefined : '.kent.pw' + }; + + console.log('Clearing cookie with options:', cookieOptions); + res.clearCookie('token', cookieOptions); + res.json({ success: true }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Server error:', err); + res.status(500).json({ + success: false, + message: 'Internal server error', + error: err.message + }); +}); + +// Start server +app.listen(PORT, () => { + console.log(`Auth server running on port ${PORT}`); + console.log('Environment:', process.env.NODE_ENV); + console.log('CORS origins:', corsOptions.origin); + console.log('JWT_SECRET length:', JWT_SECRET?.length); + console.log('DASHBOARD_PASSWORD length:', DASHBOARD_PASSWORD?.length); +}); \ No newline at end of file diff --git a/inventory-server/dashboard/auth-server/package-lock.json b/inventory-server/dashboard/auth-server/package-lock.json new file mode 100644 index 0000000..3173680 --- /dev/null +++ b/inventory-server/dashboard/auth-server/package-lock.json @@ -0,0 +1,1044 @@ +{ + "name": "auth-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "auth-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "dotenv": "^16.4.7", + "express": "^4.21.1", + "express-session": "^1.18.1", + "jsonwebtoken": "^9.0.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "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": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/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.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "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/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": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/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/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.1.0.tgz", + "integrity": "sha512-QLdzI9IIO1Jg7f9GT1gXpPpXArAn6cS31R1eEZqz08Gc+uQ8/XiqHWt17Fiw+2p6oTTIq5GXEpQkAlA88YRl/Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7" + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/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/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "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/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "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/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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" + } + } + } +} diff --git a/inventory-server/dashboard/auth-server/package.json b/inventory-server/dashboard/auth-server/package.json new file mode 100644 index 0000000..9254323 --- /dev/null +++ b/inventory-server/dashboard/auth-server/package.json @@ -0,0 +1,22 @@ +{ + "name": "auth-server", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "dotenv": "^16.4.7", + "express": "^4.21.1", + "express-session": "^1.18.1", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/inventory-server/dashboard/dashboard.conf b/inventory-server/dashboard/dashboard.conf new file mode 120000 index 0000000..d551725 --- /dev/null +++ b/inventory-server/dashboard/dashboard.conf @@ -0,0 +1 @@ +/etc/nginx/sites-enabled/dashboard.conf \ No newline at end of file diff --git a/inventory-server/dashboard/google-server/package-lock.json b/inventory-server/dashboard/google-server/package-lock.json new file mode 100644 index 0000000..f64b1ed --- /dev/null +++ b/inventory-server/dashboard/google-server/package-lock.json @@ -0,0 +1,2538 @@ +{ + "name": "google-analytics-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "google-analytics-server", + "version": "1.0.0", + "dependencies": { + "@google-analytics/data": "^4.0.0", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "redis": "^4.6.11", + "winston": "^3.11.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@google-analytics/data": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@google-analytics/data/-/data-4.12.0.tgz", + "integrity": "sha512-2s14EPCkmIANj5Jm7+VWrg4Nqkuq6kKo6UH+0s+UGd4yCvKr8BdLBW0llws04wWQOSqzLL1nv0tu179YxjaKFQ==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^4.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.12.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.5.tgz", + "integrity": "sha512-d3iiHxdpg5+ZcJ6jnDSOT8Z0O0VMVGy34jAnYLUX8yd36b1qn8f1TwOA/Lc7TsOh03IkPJ38eGI5qD2EjNkoEA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", + "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.12", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz", + "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==", + "license": "MIT", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/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/ansi-styles/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/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "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/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "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.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "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/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "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/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "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/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "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.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.0.tgz", + "integrity": "sha512-7ccSEJFDFO7exFbO6NRyC+xH8/mZ1GZGG2xxx9iHxZWcjUjJpjWxIMw3cofAKcueZ6DATiukmmprD7yavQHOyQ==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.4.1.tgz", + "integrity": "sha512-Phyp9fMfA00J3sZbJxbbB4jC55b7DBjE3F6poyL3wKMEBVKA79q6BGuHcTiM28yOzVql0NDbRL8MLLh8Iwk9Dg==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "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/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/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/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/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/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "license": "Apache-2.0" + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "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/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/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/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/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/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/inventory-server/dashboard/google-server/package.json b/inventory-server/dashboard/google-server/package.json new file mode 100644 index 0000000..3460f91 --- /dev/null +++ b/inventory-server/dashboard/google-server/package.json @@ -0,0 +1,21 @@ +{ + "name": "google-analytics-server", + "version": "1.0.0", + "description": "Google Analytics server for dashboard", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "@google-analytics/data": "^4.0.0", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "redis": "^4.6.11", + "winston": "^3.11.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/inventory-server/dashboard/google-server/routes/analytics.js b/inventory-server/dashboard/google-server/routes/analytics.js new file mode 100644 index 0000000..db2ff80 --- /dev/null +++ b/inventory-server/dashboard/google-server/routes/analytics.js @@ -0,0 +1,254 @@ +const express = require('express'); +const { BetaAnalyticsDataClient } = require('@google-analytics/data'); +const router = express.Router(); +const logger = require('../utils/logger'); + +// Initialize GA4 client +const analyticsClient = new BetaAnalyticsDataClient({ + credentials: JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON) +}); + +const propertyId = process.env.GA_PROPERTY_ID; + +// Cache durations +const CACHE_DURATIONS = { + REALTIME_BASIC: 60, // 1 minute + REALTIME_DETAILED: 300, // 5 minutes + BASIC_METRICS: 3600, // 1 hour + USER_BEHAVIOR: 3600 // 1 hour +}; + +// Basic metrics endpoint +router.get('/metrics', async (req, res) => { + try { + const { startDate = '7daysAgo' } = req.query; + const cacheKey = `analytics:basic_metrics:${startDate}`; + + // Check Redis cache + const cachedData = await req.redisClient.get(cacheKey); + if (cachedData) { + logger.info('Returning cached basic metrics data'); + return res.json({ success: true, data: JSON.parse(cachedData) }); + } + + // Fetch from GA4 + const [response] = await analyticsClient.runReport({ + property: `properties/${propertyId}`, + dateRanges: [{ startDate, endDate: 'today' }], + dimensions: [{ name: 'date' }], + metrics: [ + { name: 'activeUsers' }, + { name: 'newUsers' }, + { name: 'averageSessionDuration' }, + { name: 'screenPageViews' }, + { name: 'bounceRate' }, + { name: 'conversions' } + ], + returnPropertyQuota: true + }); + + // Cache the response + await req.redisClient.set(cacheKey, JSON.stringify(response), { + EX: CACHE_DURATIONS.BASIC_METRICS + }); + + res.json({ success: true, data: response }); + } catch (error) { + logger.error('Error fetching basic metrics:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// Realtime basic data endpoint +router.get('/realtime/basic', async (req, res) => { + try { + const cacheKey = 'analytics:realtime:basic'; + + // Check Redis cache + const cachedData = await req.redisClient.get(cacheKey); + if (cachedData) { + logger.info('Returning cached realtime basic data'); + return res.json({ success: true, data: JSON.parse(cachedData) }); + } + + // Fetch active users + const [userResponse] = await analyticsClient.runRealtimeReport({ + property: `properties/${propertyId}`, + metrics: [{ name: 'activeUsers' }], + returnPropertyQuota: true + }); + + // Fetch last 5 minutes + const [fiveMinResponse] = await analyticsClient.runRealtimeReport({ + property: `properties/${propertyId}`, + metrics: [{ name: 'activeUsers' }], + minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }] + }); + + // Fetch time series data + const [timeSeriesResponse] = await analyticsClient.runRealtimeReport({ + property: `properties/${propertyId}`, + dimensions: [{ name: 'minutesAgo' }], + metrics: [{ name: 'activeUsers' }] + }); + + const response = { + userResponse, + fiveMinResponse, + timeSeriesResponse, + quotaInfo: { + projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour, + daily: userResponse.propertyQuota.tokensPerDay, + serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour, + thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour + } + }; + + // Cache the response + await req.redisClient.set(cacheKey, JSON.stringify(response), { + EX: CACHE_DURATIONS.REALTIME_BASIC + }); + + res.json({ success: true, data: response }); + } catch (error) { + logger.error('Error fetching realtime basic data:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// Realtime detailed data endpoint +router.get('/realtime/detailed', async (req, res) => { + try { + const cacheKey = 'analytics:realtime:detailed'; + + // Check Redis cache + const cachedData = await req.redisClient.get(cacheKey); + if (cachedData) { + logger.info('Returning cached realtime detailed data'); + return res.json({ success: true, data: JSON.parse(cachedData) }); + } + + // Fetch current pages + const [pageResponse] = await analyticsClient.runRealtimeReport({ + property: `properties/${propertyId}`, + dimensions: [{ name: 'unifiedScreenName' }], + metrics: [{ name: 'screenPageViews' }], + orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }], + limit: 25 + }); + + // Fetch events + const [eventResponse] = await analyticsClient.runRealtimeReport({ + property: `properties/${propertyId}`, + dimensions: [{ name: 'eventName' }], + metrics: [{ name: 'eventCount' }], + orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }], + limit: 25 + }); + + // Fetch device categories + const [deviceResponse] = await analyticsClient.runRealtimeReport({ + property: `properties/${propertyId}`, + dimensions: [{ name: 'deviceCategory' }], + metrics: [{ name: 'activeUsers' }], + orderBy: [{ metric: { metricName: 'activeUsers' }, desc: true }], + limit: 10, + returnPropertyQuota: true + }); + + const response = { + pageResponse, + eventResponse, + sourceResponse: deviceResponse + }; + + // Cache the response + await req.redisClient.set(cacheKey, JSON.stringify(response), { + EX: CACHE_DURATIONS.REALTIME_DETAILED + }); + + res.json({ success: true, data: response }); + } catch (error) { + logger.error('Error fetching realtime detailed data:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// User behavior endpoint +router.get('/user-behavior', async (req, res) => { + try { + const { timeRange = '30' } = req.query; + const cacheKey = `analytics:user_behavior:${timeRange}`; + + // Check Redis cache + const cachedData = await req.redisClient.get(cacheKey); + if (cachedData) { + logger.info('Returning cached user behavior data'); + return res.json({ success: true, data: JSON.parse(cachedData) }); + } + + // Fetch page data + const [pageResponse] = await analyticsClient.runReport({ + property: `properties/${propertyId}`, + dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }], + dimensions: [{ name: 'pagePath' }], + metrics: [ + { name: 'screenPageViews' }, + { name: 'averageSessionDuration' }, + { name: 'bounceRate' }, + { name: 'sessions' } + ], + orderBy: [{ + metric: { metricName: 'screenPageViews' }, + desc: true + }], + limit: 25 + }); + + // Fetch device data + const [deviceResponse] = await analyticsClient.runReport({ + property: `properties/${propertyId}`, + dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }], + dimensions: [{ name: 'deviceCategory' }], + metrics: [ + { name: 'screenPageViews' }, + { name: 'sessions' } + ] + }); + + // Fetch source data + const [sourceResponse] = await analyticsClient.runReport({ + property: `properties/${propertyId}`, + dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }], + dimensions: [{ name: 'sessionSource' }], + metrics: [ + { name: 'sessions' }, + { name: 'conversions' } + ], + orderBy: [{ + metric: { metricName: 'sessions' }, + desc: true + }], + limit: 25, + returnPropertyQuota: true + }); + + const response = { + pageResponse, + deviceResponse, + sourceResponse + }; + + // Cache the response + await req.redisClient.set(cacheKey, JSON.stringify(response), { + EX: CACHE_DURATIONS.USER_BEHAVIOR + }); + + res.json({ success: true, data: response }); + } catch (error) { + logger.error('Error fetching user behavior data:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/dashboard/google-server/routes/analytics.routes.js b/inventory-server/dashboard/google-server/routes/analytics.routes.js new file mode 100644 index 0000000..2d547a8 --- /dev/null +++ b/inventory-server/dashboard/google-server/routes/analytics.routes.js @@ -0,0 +1,91 @@ +const express = require('express'); +const router = express.Router(); +const analyticsService = require('../services/analytics.service'); + +// Basic metrics endpoint +router.get('/metrics', async (req, res) => { + try { + const { startDate = '7daysAgo' } = req.query; + console.log(`Fetching metrics with startDate: ${startDate}`); + + const data = await analyticsService.getBasicMetrics(startDate); + res.json({ success: true, data }); + } catch (error) { + console.error('Metrics error:', { + startDate: req.query.startDate, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: 'Failed to fetch metrics', + details: error.message + }); + } +}); + +// Realtime basic data endpoint +router.get('/realtime/basic', async (req, res) => { + try { + console.log('Fetching realtime basic data'); + const data = await analyticsService.getRealTimeBasicData(); + res.json({ success: true, data }); + } catch (error) { + console.error('Realtime basic error:', { + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: 'Failed to fetch realtime basic data', + details: error.message + }); + } +}); + +// Realtime detailed data endpoint +router.get('/realtime/detailed', async (req, res) => { + try { + console.log('Fetching realtime detailed data'); + const data = await analyticsService.getRealTimeDetailedData(); + res.json({ success: true, data }); + } catch (error) { + console.error('Realtime detailed error:', { + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: 'Failed to fetch realtime detailed data', + details: error.message + }); + } +}); + +// User behavior endpoint +router.get('/user-behavior', async (req, res) => { + try { + const { timeRange = '30' } = req.query; + console.log(`Fetching user behavior with timeRange: ${timeRange}`); + + const data = await analyticsService.getUserBehavior(timeRange); + res.json({ success: true, data }); + } catch (error) { + console.error('User behavior error:', { + timeRange: req.query.timeRange, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: 'Failed to fetch user behavior data', + details: error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/dashboard/google-server/server.js b/inventory-server/dashboard/google-server/server.js new file mode 100644 index 0000000..bcc42a2 --- /dev/null +++ b/inventory-server/dashboard/google-server/server.js @@ -0,0 +1,65 @@ +const express = require('express'); +const cors = require('cors'); +const { createClient } = require('redis'); +const analyticsRoutes = require('./routes/analytics.routes'); + +const app = express(); +const port = process.env.GOOGLE_ANALYTICS_PORT || 3007; + +// Redis client setup +const redisClient = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379' +}); + +redisClient.on('error', (err) => console.error('Redis Client Error:', err)); +redisClient.on('connect', () => console.log('Redis Client Connected')); + +// Connect to Redis +(async () => { + try { + await redisClient.connect(); + } catch (err) { + console.error('Redis connection error:', err); + } +})(); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Make Redis client available in requests +app.use((req, res, next) => { + req.redisClient = redisClient; + next(); +}); + +// Routes +app.use('/api/analytics', analyticsRoutes); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Server error:', err); + res.status(err.status || 500).json({ + success: false, + message: err.message || 'Internal server error', + error: process.env.NODE_ENV === 'production' ? err : {} + }); +}); + +// Start server +app.listen(port, () => { + console.log(`Google Analytics server running on port ${port}`); +}); + +// Handle graceful shutdown +process.on('SIGTERM', async () => { + console.log('SIGTERM received. Shutting down gracefully...'); + await redisClient.quit(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('SIGINT received. Shutting down gracefully...'); + await redisClient.quit(); + process.exit(0); +}); \ No newline at end of file diff --git a/inventory-server/dashboard/google-server/services/analytics.service.js b/inventory-server/dashboard/google-server/services/analytics.service.js new file mode 100644 index 0000000..6f1456a --- /dev/null +++ b/inventory-server/dashboard/google-server/services/analytics.service.js @@ -0,0 +1,283 @@ +const { BetaAnalyticsDataClient } = require('@google-analytics/data'); +const { createClient } = require('redis'); + +class AnalyticsService { + constructor() { + // Initialize Redis client + this.redis = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379' + }); + + this.redis.on('error', err => console.error('Redis Client Error:', err)); + this.redis.connect().catch(err => console.error('Redis connection error:', err)); + + try { + // Initialize GA4 client + const credentials = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON; + this.analyticsClient = new BetaAnalyticsDataClient({ + credentials: typeof credentials === 'string' ? JSON.parse(credentials) : credentials + }); + + this.propertyId = process.env.GA_PROPERTY_ID; + } catch (error) { + console.error('Failed to initialize GA4 client:', error); + throw error; + } + } + + // Cache durations + CACHE_DURATIONS = { + REALTIME_BASIC: 60, // 1 minute + REALTIME_DETAILED: 300, // 5 minutes + BASIC_METRICS: 3600, // 1 hour + USER_BEHAVIOR: 3600 // 1 hour + }; + + async getBasicMetrics(startDate = '7daysAgo') { + const cacheKey = `analytics:basic_metrics:${startDate}`; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + console.log('Analytics metrics found in Redis cache'); + return JSON.parse(cachedData); + } + + // Fetch from GA4 + console.log('Fetching fresh metrics data from GA4'); + const [response] = await this.analyticsClient.runReport({ + property: `properties/${this.propertyId}`, + dateRanges: [{ startDate, endDate: 'today' }], + dimensions: [{ name: 'date' }], + metrics: [ + { name: 'activeUsers' }, + { name: 'newUsers' }, + { name: 'averageSessionDuration' }, + { name: 'screenPageViews' }, + { name: 'bounceRate' }, + { name: 'conversions' } + ], + returnPropertyQuota: true + }); + + // Cache the response + await this.redis.set(cacheKey, JSON.stringify(response), { + EX: this.CACHE_DURATIONS.BASIC_METRICS + }); + + return response; + } catch (error) { + console.error('Error fetching analytics metrics:', { + error: error.message, + stack: error.stack + }); + throw error; + } + } + + async getRealTimeBasicData() { + const cacheKey = 'analytics:realtime:basic'; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + console.log('Realtime basic data found in Redis cache'); + return JSON.parse(cachedData); + } + + console.log('Fetching fresh realtime data from GA4'); + + // Fetch active users + const [userResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + metrics: [{ name: 'activeUsers' }], + returnPropertyQuota: true + }); + + // Fetch last 5 minutes + const [fiveMinResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + metrics: [{ name: 'activeUsers' }], + minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }] + }); + + // Fetch time series data + const [timeSeriesResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + dimensions: [{ name: 'minutesAgo' }], + metrics: [{ name: 'activeUsers' }] + }); + + const response = { + userResponse, + fiveMinResponse, + timeSeriesResponse, + quotaInfo: { + projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour, + daily: userResponse.propertyQuota.tokensPerDay, + serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour, + thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour + } + }; + + // Cache the response + await this.redis.set(cacheKey, JSON.stringify(response), { + EX: this.CACHE_DURATIONS.REALTIME_BASIC + }); + + return response; + } catch (error) { + console.error('Error fetching realtime basic data:', { + error: error.message, + stack: error.stack + }); + throw error; + } + } + + async getRealTimeDetailedData() { + const cacheKey = 'analytics:realtime:detailed'; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + console.log('Realtime detailed data found in Redis cache'); + return JSON.parse(cachedData); + } + + console.log('Fetching fresh realtime detailed data from GA4'); + + // Fetch current pages + const [pageResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + dimensions: [{ name: 'unifiedScreenName' }], + metrics: [{ name: 'screenPageViews' }], + orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }], + limit: 25 + }); + + // Fetch events + const [eventResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + dimensions: [{ name: 'eventName' }], + metrics: [{ name: 'eventCount' }], + orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }], + limit: 25 + }); + + // Fetch device categories + const [deviceResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + dimensions: [{ name: 'deviceCategory' }], + metrics: [{ name: 'activeUsers' }], + orderBy: [{ metric: { metricName: 'activeUsers' }, desc: true }], + limit: 10, + returnPropertyQuota: true + }); + + const response = { + pageResponse, + eventResponse, + sourceResponse: deviceResponse + }; + + // Cache the response + await this.redis.set(cacheKey, JSON.stringify(response), { + EX: this.CACHE_DURATIONS.REALTIME_DETAILED + }); + + return response; + } catch (error) { + console.error('Error fetching realtime detailed data:', { + error: error.message, + stack: error.stack + }); + throw error; + } + } + + async getUserBehavior(timeRange = '30') { + const cacheKey = `analytics:user_behavior:${timeRange}`; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + console.log('User behavior data found in Redis cache'); + return JSON.parse(cachedData); + } + + console.log('Fetching fresh user behavior data from GA4'); + + // Fetch page data + const [pageResponse] = await this.analyticsClient.runReport({ + property: `properties/${this.propertyId}`, + dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }], + dimensions: [{ name: 'pagePath' }], + metrics: [ + { name: 'screenPageViews' }, + { name: 'averageSessionDuration' }, + { name: 'bounceRate' }, + { name: 'sessions' } + ], + orderBy: [{ + metric: { metricName: 'screenPageViews' }, + desc: true + }], + limit: 25 + }); + + // Fetch device data + const [deviceResponse] = await this.analyticsClient.runReport({ + property: `properties/${this.propertyId}`, + dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }], + dimensions: [{ name: 'deviceCategory' }], + metrics: [ + { name: 'screenPageViews' }, + { name: 'sessions' } + ] + }); + + // Fetch source data + const [sourceResponse] = await this.analyticsClient.runReport({ + property: `properties/${this.propertyId}`, + dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }], + dimensions: [{ name: 'sessionSource' }], + metrics: [ + { name: 'sessions' }, + { name: 'conversions' } + ], + orderBy: [{ + metric: { metricName: 'sessions' }, + desc: true + }], + limit: 25, + returnPropertyQuota: true + }); + + const response = { + pageResponse, + deviceResponse, + sourceResponse + }; + + // Cache the response + await this.redis.set(cacheKey, JSON.stringify(response), { + EX: this.CACHE_DURATIONS.USER_BEHAVIOR + }); + + return response; + } catch (error) { + console.error('Error fetching user behavior data:', { + error: error.message, + stack: error.stack + }); + throw error; + } + } +} + +module.exports = new AnalyticsService(); \ No newline at end of file diff --git a/inventory-server/dashboard/google-server/utils/logger.js b/inventory-server/dashboard/google-server/utils/logger.js new file mode 100644 index 0000000..980928e --- /dev/null +++ b/inventory-server/dashboard/google-server/utils/logger.js @@ -0,0 +1,35 @@ +const winston = require('winston'); +const path = require('path'); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.File({ + filename: path.join(__dirname, '../logs/pm2/error.log'), + level: 'error', + maxsize: 10485760, // 10MB + maxFiles: 5 + }), + new winston.transports.File({ + filename: path.join(__dirname, '../logs/pm2/combined.log'), + maxsize: 10485760, // 10MB + maxFiles: 5 + }) + ] +}); + +// Add console transport in development +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + })); +} + +module.exports = logger; \ No newline at end of file diff --git a/inventory-server/dashboard/gorgias-server/package-lock.json b/inventory-server/dashboard/gorgias-server/package-lock.json new file mode 100644 index 0000000..9242a0b --- /dev/null +++ b/inventory-server/dashboard/gorgias-server/package-lock.json @@ -0,0 +1,1068 @@ +{ + "name": "gorgias-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gorgias-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.7.9", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "redis": "^4.7.0" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "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": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "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.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "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": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "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.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/redis": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "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/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/inventory-server/dashboard/gorgias-server/package.json b/inventory-server/dashboard/gorgias-server/package.json new file mode 100644 index 0000000..6240316 --- /dev/null +++ b/inventory-server/dashboard/gorgias-server/package.json @@ -0,0 +1,19 @@ +{ + "name": "gorgias-server", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "axios": "^1.7.9", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "redis": "^4.7.0" + } +} diff --git a/inventory-server/dashboard/gorgias-server/routes/gorgias.routes.js b/inventory-server/dashboard/gorgias-server/routes/gorgias.routes.js new file mode 100644 index 0000000..3a6199d --- /dev/null +++ b/inventory-server/dashboard/gorgias-server/routes/gorgias.routes.js @@ -0,0 +1,119 @@ +const express = require('express'); +const router = express.Router(); +const gorgiasService = require('../services/gorgias.service'); + +// Get statistics +router.post('/stats/:name', async (req, res) => { + try { + const { name } = req.params; + const filters = req.body; + + console.log(`Fetching ${name} statistics with filters:`, filters); + + if (!name) { + return res.status(400).json({ + error: 'Missing statistic name', + details: 'The name parameter is required' + }); + } + + const data = await gorgiasService.getStatistics(name, filters); + + if (!data) { + return res.status(404).json({ + error: 'No data found', + details: `No statistics found for ${name}` + }); + } + + res.json({ data }); + } catch (error) { + console.error('Statistics error:', { + name: req.params.name, + filters: req.body, + error: error.message, + stack: error.stack, + response: error.response?.data + }); + + // Handle specific error cases + if (error.response?.status === 401) { + return res.status(401).json({ + error: 'Authentication failed', + details: 'Invalid Gorgias API credentials' + }); + } + + if (error.response?.status === 404) { + return res.status(404).json({ + error: 'Not found', + details: `Statistics type '${req.params.name}' not found` + }); + } + + if (error.response?.status === 400) { + return res.status(400).json({ + error: 'Invalid request', + details: error.response?.data?.message || 'The request was invalid', + data: error.response?.data + }); + } + + res.status(500).json({ + error: 'Failed to fetch statistics', + details: error.response?.data?.message || error.message, + data: error.response?.data + }); + } +}); + +// Get tickets +router.get('/tickets', async (req, res) => { + try { + const data = await gorgiasService.getTickets(req.query); + res.json(data); + } catch (error) { + console.error('Tickets error:', { + params: req.query, + error: error.message, + response: error.response?.data + }); + + if (error.response?.status === 401) { + return res.status(401).json({ + error: 'Authentication failed', + details: 'Invalid Gorgias API credentials' + }); + } + + if (error.response?.status === 400) { + return res.status(400).json({ + error: 'Invalid request', + details: error.response?.data?.message || 'The request was invalid', + data: error.response?.data + }); + } + + res.status(500).json({ + error: 'Failed to fetch tickets', + details: error.response?.data?.message || error.message, + data: error.response?.data + }); + } +}); + +// Get customer satisfaction +router.get('/satisfaction', async (req, res) => { + try { + const data = await gorgiasService.getCustomerSatisfaction(req.query); + res.json(data); + } catch (error) { + console.error('Satisfaction error:', error); + res.status(500).json({ + error: 'Failed to fetch customer satisfaction', + details: error.response?.data || error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/dashboard/gorgias-server/server.js b/inventory-server/dashboard/gorgias-server/server.js new file mode 100644 index 0000000..f0c05c4 --- /dev/null +++ b/inventory-server/dashboard/gorgias-server/server.js @@ -0,0 +1,31 @@ +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +require('dotenv').config({ + path: path.resolve(__dirname, '.env') +}); + +const app = express(); +const port = process.env.PORT || 3006; + +app.use(cors()); +app.use(express.json()); + +// Import routes +const gorgiasRoutes = require('./routes/gorgias.routes'); + +// Use routes +app.use('/api/gorgias', gorgiasRoutes); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Something went wrong!' }); +}); + +// Start server +app.listen(port, () => { + console.log(`Gorgias API server running on port ${port}`); +}); + +module.exports = app; diff --git a/inventory-server/dashboard/gorgias-server/services/gorgias.service.js b/inventory-server/dashboard/gorgias-server/services/gorgias.service.js new file mode 100644 index 0000000..a34a41e --- /dev/null +++ b/inventory-server/dashboard/gorgias-server/services/gorgias.service.js @@ -0,0 +1,119 @@ +const axios = require('axios'); +const { createClient } = require('redis'); + +class GorgiasService { + constructor() { + this.redis = createClient({ + url: process.env.REDIS_URL + }); + + this.redis.on('error', err => console.error('Redis Client Error:', err)); + this.redis.connect().catch(err => console.error('Redis connection error:', err)); + + // Create base64 encoded auth string + const auth = Buffer.from(`${process.env.GORGIAS_API_USERNAME}:${process.env.GORGIAS_API_KEY}`).toString('base64'); + + this.apiClient = axios.create({ + baseURL: `https://${process.env.GORGIAS_DOMAIN}.gorgias.com/api`, + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json' + } + }); + } + + async getStatistics(name, filters = {}) { + const cacheKey = `gorgias:stats:${name}:${JSON.stringify(filters)}`; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + console.log(`Statistics ${name} found in Redis cache`); + return JSON.parse(cachedData); + } + + console.log(`Fetching ${name} statistics with filters:`, filters); + + // Convert dates to UTC midnight if not already set + if (!filters.start_datetime || !filters.end_datetime) { + const start = new Date(filters.start_datetime || filters.start_date); + start.setUTCHours(0, 0, 0, 0); + const end = new Date(filters.end_datetime || filters.end_date); + end.setUTCHours(23, 59, 59, 999); + + filters = { + ...filters, + start_datetime: start.toISOString(), + end_datetime: end.toISOString() + }; + } + + // Fetch from API + const response = await this.apiClient.post(`/stats/${name}`, filters); + const data = response.data; + + // Save to Redis with 5 minute expiry + await this.redis.set(cacheKey, JSON.stringify(data), { + EX: 300 // 5 minutes + }); + + return data; + } catch (error) { + console.error(`Error in getStatistics for ${name}:`, { + error: error.message, + filters, + response: error.response?.data + }); + throw error; + } + } + + async getTickets(params = {}) { + const cacheKey = `gorgias:tickets:${JSON.stringify(params)}`; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + console.log('Tickets found in Redis cache'); + return JSON.parse(cachedData); + } + + // Convert dates to UTC midnight + const formattedParams = { ...params }; + if (params.start_date) { + const start = new Date(params.start_date); + start.setUTCHours(0, 0, 0, 0); + formattedParams.start_datetime = start.toISOString(); + delete formattedParams.start_date; + } + if (params.end_date) { + const end = new Date(params.end_date); + end.setUTCHours(23, 59, 59, 999); + formattedParams.end_datetime = end.toISOString(); + delete formattedParams.end_date; + } + + // Fetch from API + const response = await this.apiClient.get('/tickets', { params: formattedParams }); + const data = response.data; + + // Save to Redis with 5 minute expiry + await this.redis.set(cacheKey, JSON.stringify(data), { + EX: 300 // 5 minutes + }); + + return data; + } catch (error) { + console.error('Error fetching tickets:', { + error: error.message, + params, + response: error.response?.data + }); + throw error; + } + } +} + +module.exports = new GorgiasService(); \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/package-lock.json b/inventory-server/dashboard/klaviyo-server/package-lock.json new file mode 100644 index 0000000..5fd3a9e --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/package-lock.json @@ -0,0 +1,1957 @@ +{ + "name": "klaviyo-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "klaviyo-server", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "esm": "^3.2.25", + "express": "^4.18.2", + "express-rate-limit": "^7.5.0", + "ioredis": "^5.4.1", + "luxon": "^3.5.0", + "node-fetch": "^3.3.2", + "recharts": "^2.15.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.2.tgz", + "integrity": "sha512-0lk0PHFe/uz0vl527fG9CgdE9WdafjDbCXvBbs+LUv000TVt2Jjhqbs4Jwm8gz070w8xXyEAxrPOMullsxXeGg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "get-intrinsic": "^1.2.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz", + "integrity": "sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "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.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "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/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/fast-equals": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ioredis": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/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/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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.0.0.tgz", + "integrity": "sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "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/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.3.tgz", + "integrity": "sha512-PyxIrra8WZWrMRFcCiJsZ+JqFaxEINAt+v/w++wQKQlmO99Eh3+JTLeKApdTsLX2roBdWYXqPsaS8sO4UmdzIg==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", + "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/inventory-server/dashboard/klaviyo-server/package.json b/inventory-server/dashboard/klaviyo-server/package.json new file mode 100644 index 0000000..021f450 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "klaviyo-server", + "version": "1.0.0", + "description": "Klaviyo API integration server", + "main": "server.js", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "esm": "^3.2.25", + "express": "^4.18.2", + "express-rate-limit": "^7.5.0", + "ioredis": "^5.4.1", + "luxon": "^3.5.0", + "node-fetch": "^3.3.2", + "recharts": "^2.15.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/inventory-server/dashboard/klaviyo-server/routes/campaigns.routes.js b/inventory-server/dashboard/klaviyo-server/routes/campaigns.routes.js new file mode 100644 index 0000000..2212278 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/routes/campaigns.routes.js @@ -0,0 +1,71 @@ +import express from 'express'; +import { CampaignsService } from '../services/campaigns.service.js'; +import { TimeManager } from '../utils/time.utils.js'; + +export function createCampaignsRouter(apiKey, apiRevision) { + const router = express.Router(); + const timeManager = new TimeManager(); + const campaignsService = new CampaignsService(apiKey, apiRevision); + + // Get campaigns with optional filtering + router.get('/', async (req, res) => { + try { + const params = { + pageSize: parseInt(req.query.pageSize) || 50, + sort: req.query.sort || '-send_time', + status: req.query.status, + startDate: req.query.startDate, + endDate: req.query.endDate, + pageCursor: req.query.pageCursor + }; + + console.log('[Campaigns Route] Fetching campaigns with params:', params); + const data = await campaignsService.getCampaigns(params); + console.log('[Campaigns Route] Success:', { + count: data.data?.length || 0 + }); + res.json(data); + } catch (error) { + console.error('[Campaigns Route] Error:', error); + res.status(500).json({ + status: 'error', + message: error.message, + details: error.response?.data || null + }); + } + }); + + // Get campaigns by time range + router.get('/:timeRange', async (req, res) => { + try { + const { timeRange } = req.params; + const { status } = req.query; + + let result; + if (timeRange === 'custom') { + const { startDate, endDate } = req.query; + if (!startDate || !endDate) { + return res.status(400).json({ error: 'Custom range requires startDate and endDate' }); + } + + result = await campaignsService.getCampaigns({ + startDate, + endDate, + status + }); + } else { + result = await campaignsService.getCampaignsByTimeRange( + timeRange, + { status } + ); + } + + res.json(result); + } catch (error) { + console.error("[Campaigns Route] Error:", error); + res.status(500).json({ error: error.message }); + } + }); + + return router; +} \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/routes/events.routes.js b/inventory-server/dashboard/klaviyo-server/routes/events.routes.js new file mode 100644 index 0000000..9e25f06 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/routes/events.routes.js @@ -0,0 +1,480 @@ +import express from 'express'; +import { EventsService } from '../services/events.service.js'; +import { TimeManager } from '../utils/time.utils.js'; +import { RedisService } from '../services/redis.service.js'; + +// Import METRIC_IDS from events service +const METRIC_IDS = { + PLACED_ORDER: 'Y8cqcF', + SHIPPED_ORDER: 'VExpdL', + ACCOUNT_CREATED: 'TeeypV', + CANCELED_ORDER: 'YjVMNg', + NEW_BLOG_POST: 'YcxeDr', + PAYMENT_REFUNDED: 'R7XUYh' +}; + +export function createEventsRouter(apiKey, apiRevision) { + const router = express.Router(); + const timeManager = new TimeManager(); + const eventsService = new EventsService(apiKey, apiRevision); + const redisService = new RedisService(); + + // Get events with optional filtering + router.get('/', async (req, res) => { + try { + const params = { + pageSize: parseInt(req.query.pageSize) || 50, + sort: req.query.sort || '-datetime', + metricId: req.query.metricId, + startDate: req.query.startDate, + endDate: req.query.endDate, + pageCursor: req.query.pageCursor, + fields: {} + }; + + // Parse fields parameter if provided + if (req.query.fields) { + try { + params.fields = JSON.parse(req.query.fields); + } catch (e) { + console.warn('[Events Route] Invalid fields parameter:', e); + } + } + + console.log('[Events Route] Fetching events with params:', params); + const data = await eventsService.getEvents(params); + console.log('[Events Route] Success:', { + count: data.data?.length || 0, + included: data.included?.length || 0 + }); + res.json(data); + } catch (error) { + console.error('[Events Route] Error:', error); + res.status(500).json({ + status: 'error', + message: error.message, + details: error.response?.data || null + }); + } + }); + + // Get events by time range + router.get('/by-time/:timeRange', async (req, res) => { + try { + const { timeRange } = req.params; + const { metricId, startDate, endDate } = req.query; + + let result; + if (timeRange === 'custom') { + if (!startDate || !endDate) { + return res.status(400).json({ error: 'Custom range requires startDate and endDate' }); + } + + const range = timeManager.getCustomRange(startDate, endDate); + if (!range) { + return res.status(400).json({ error: 'Invalid date range' }); + } + + result = await eventsService.getEvents({ + metricId, + startDate: range.start.toISO(), + endDate: range.end.toISO() + }); + } else { + result = await eventsService.getEventsByTimeRange( + timeRange, + { metricId } + ); + } + + res.json(result); + } catch (error) { + console.error("[Events Route] Error:", error); + res.status(500).json({ error: error.message }); + } + }); + + // Get comprehensive statistics for a time period + router.get('/stats', async (req, res) => { + try { + const { timeRange, startDate, endDate } = req.query; + console.log('[Events Route] Stats request:', { + timeRange, + startDate, + endDate + }); + + let range; + if (startDate && endDate) { + range = timeManager.getCustomRange(startDate, endDate); + } else if (timeRange) { + range = timeManager.getDateRange(timeRange); + } else { + return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' }); + } + + if (!range) { + return res.status(400).json({ error: 'Invalid time range' }); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO() + }; + + console.log('[Events Route] Calculating period stats with params:', params); + const stats = await eventsService.calculatePeriodStats(params); + console.log('[Events Route] Stats response:', { + timeRange: { + start: range.start.toISO(), + end: range.end.toISO() + }, + shippedCount: stats?.shipping?.shippedCount, + totalOrders: stats?.orderCount + }); + + res.json({ + timeRange: { + start: range.start.toISO(), + end: range.end.toISO(), + displayStart: timeManager.formatForDisplay(range.start), + displayEnd: timeManager.formatForDisplay(range.end) + }, + stats + }); + } catch (error) { + console.error("[Events Route] Error:", error); + res.status(500).json({ error: error.message }); + } + }); + + // Add new route for smart revenue projection + router.get('/projection', async (req, res) => { + try { + const { timeRange, startDate, endDate } = req.query; + console.log('[Events Route] Projection request:', { + timeRange, + startDate, + endDate + }); + + let range; + if (startDate && endDate) { + range = timeManager.getCustomRange(startDate, endDate); + } else if (timeRange) { + range = timeManager.getDateRange(timeRange); + } else { + return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' }); + } + + if (!range) { + return res.status(400).json({ error: 'Invalid time range' }); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO() + }; + + // Try to get from cache first with a short TTL + const cacheKey = redisService._getCacheKey('projection', params); + const cachedData = await redisService.get(cacheKey); + + if (cachedData) { + console.log('[Events Route] Cache hit for projection'); + return res.json(cachedData); + } + + console.log('[Events Route] Calculating smart projection with params:', params); + const projection = await eventsService.calculateSmartProjection(params); + + // Cache the results with a short TTL (5 minutes) + await redisService.set(cacheKey, projection, 300); + + res.json(projection); + } catch (error) { + console.error("[Events Route] Error calculating projection:", error); + res.status(500).json({ error: error.message }); + } + }); + + // Add new route for detailed stats + router.get('/stats/details', async (req, res) => { + try { + const { timeRange, startDate, endDate, metric, daily = false } = req.query; + + let range; + if (startDate && endDate) { + range = timeManager.getCustomRange(startDate, endDate); + } else if (timeRange) { + range = timeManager.getDateRange(timeRange); + } else { + return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' }); + } + + if (!range) { + return res.status(400).json({ error: 'Invalid time range' }); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO(), + metric, + daily: daily === 'true' || daily === true + }; + + // Try to get from cache first + const cacheKey = redisService._getCacheKey('stats:details', params); + const cachedData = await redisService.get(cacheKey); + + if (cachedData) { + console.log('[Events Route] Cache hit for detailed stats'); + return res.json({ + timeRange: { + start: range.start.toISO(), + end: range.end.toISO(), + displayStart: timeManager.formatForDisplay(range.start), + displayEnd: timeManager.formatForDisplay(range.end) + }, + stats: cachedData + }); + } + + const stats = await eventsService.calculateDetailedStats(params); + + // Cache the results + const ttl = redisService._getTTL(timeRange); + await redisService.set(cacheKey, stats, ttl); + + res.json({ + timeRange: { + start: range.start.toISO(), + end: range.end.toISO(), + displayStart: timeManager.formatForDisplay(range.start), + displayEnd: timeManager.formatForDisplay(range.end) + }, + stats + }); + } catch (error) { + console.error("[Events Route] Error:", error); + res.status(500).json({ error: error.message }); + } + }); + + // Get product statistics for a time period + router.get('/products', async (req, res) => { + try { + const { timeRange, startDate, endDate } = req.query; + + let range; + if (startDate && endDate) { + range = timeManager.getCustomRange(startDate, endDate); + } else if (timeRange) { + range = timeManager.getDateRange(timeRange); + } else { + return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' }); + } + + if (!range) { + return res.status(400).json({ error: 'Invalid time range' }); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO() + }; + + // Try to get from cache first + const cacheKey = redisService._getCacheKey('events', params); + const cachedData = await redisService.getEventData('products', params); + + if (cachedData) { + console.log('[Events Route] Cache hit for products'); + return res.json({ + timeRange: { + start: range.start.toISO(), + end: range.end.toISO(), + displayStart: timeManager.formatForDisplay(range.start), + displayEnd: timeManager.formatForDisplay(range.end) + }, + stats: { + products: cachedData + } + }); + } + + const stats = await eventsService.calculatePeriodStats(params); + + res.json({ + timeRange: { + start: range.start.toISO(), + end: range.end.toISO(), + displayStart: timeManager.formatForDisplay(range.start), + displayEnd: timeManager.formatForDisplay(range.end) + }, + stats + }); + } catch (error) { + console.error("[Events Route] Error:", error); + res.status(500).json({ error: error.message }); + } + }); + + // Get event feed (multiple event types sorted by time) + router.get('/feed', async (req, res) => { + try { + const { timeRange, startDate, endDate, metricIds } = req.query; + + let range; + if (startDate && endDate) { + range = timeManager.getCustomRange(startDate, endDate); + } else if (timeRange) { + range = timeManager.getDateRange(timeRange); + } else { + return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' }); + } + + if (!range) { + return res.status(400).json({ error: 'Invalid time range' }); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO(), + metricIds: metricIds ? JSON.parse(metricIds) : null + }; + + const result = await eventsService.getMultiMetricEvents(params); + + res.json({ + timeRange: { + start: range.start.toISO(), + end: range.end.toISO(), + displayStart: timeManager.formatForDisplay(range.start), + displayEnd: timeManager.formatForDisplay(range.end) + }, + ...result + }); + } catch (error) { + console.error("[Events Route] Error:", error); + res.status(500).json({ error: error.message }); + } + }); + + // Get aggregated events data + router.get('/aggregate', async (req, res) => { + try { + const { timeRange, startDate, endDate, interval = 'day', metricId, property } = req.query; + + let range; + if (startDate && endDate) { + range = timeManager.getCustomRange(startDate, endDate); + } else if (timeRange) { + range = timeManager.getDateRange(timeRange); + } else { + return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' }); + } + + if (!range) { + return res.status(400).json({ error: 'Invalid time range' }); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO(), + metricId, + interval, + property + }; + + const result = await eventsService.getEvents(params); + const groupedData = timeManager.groupEventsByInterval(result.data, interval, property); + + res.json({ + timeRange: { + start: range.start.toISO(), + end: range.end.toISO(), + displayStart: timeManager.formatForDisplay(range.start), + displayEnd: timeManager.formatForDisplay(range.end) + }, + data: groupedData + }); + } catch (error) { + console.error("[Events Route] Error:", error); + res.status(500).json({ error: error.message }); + } + }); + + // Get date range for a given time period + router.get("/dateRange", async (req, res) => { + try { + const { timeRange, startDate, endDate } = req.query; + + let range; + if (startDate && endDate) { + range = timeManager.getCustomRange(startDate, endDate); + } else { + range = timeManager.getDateRange(timeRange || 'today'); + } + + if (!range) { + return res.status(400).json({ + error: "Invalid time range parameters" + }); + } + + res.json({ + start: range.start.toISO(), + end: range.end.toISO(), + displayStart: timeManager.formatForDisplay(range.start), + displayEnd: timeManager.formatForDisplay(range.end) + }); + } catch (error) { + console.error('Error getting date range:', error); + res.status(500).json({ + error: "Failed to get date range" + }); + } + }); + + // Clear cache for a specific time range + router.post("/clearCache", async (req, res) => { + try { + const { timeRange, startDate, endDate } = req.body; + await redisService.clearCache({ timeRange, startDate, endDate }); + res.json({ message: "Cache cleared successfully" }); + } catch (error) { + console.error('Error clearing cache:', error); + res.status(500).json({ error: "Failed to clear cache" }); + } + }); + + // Add new batch metrics endpoint + router.get('/batch', async (req, res) => { + try { + const { timeRange, startDate, endDate, metrics } = req.query; + + // Parse metrics array from query + const metricsList = metrics ? JSON.parse(metrics) : []; + + const params = timeRange === 'custom' + ? { startDate, endDate, metrics: metricsList } + : { timeRange, metrics: metricsList }; + + const results = await eventsService.getBatchMetrics(params); + + res.json(results); + } catch (error) { + console.error('[Events Route] Error in batch request:', error); + res.status(500).json({ error: error.message }); + } + }); + + return router; +} \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/routes/index.js b/inventory-server/dashboard/klaviyo-server/routes/index.js new file mode 100644 index 0000000..c83c323 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/routes/index.js @@ -0,0 +1,17 @@ +import express from 'express'; +import { createEventsRouter } from './events.routes.js'; +import { createMetricsRoutes } from './metrics.routes.js'; +import { createCampaignsRouter } from './campaigns.routes.js'; +import { createReportingRouter } from './reporting.routes.js'; + +export function createApiRouter(apiKey, apiRevision) { + const router = express.Router(); + + // Mount routers + router.use('/events', createEventsRouter(apiKey, apiRevision)); + router.use('/metrics', createMetricsRoutes(apiKey, apiRevision)); + router.use('/campaigns', createCampaignsRouter(apiKey, apiRevision)); + router.use('/reporting', createReportingRouter(apiKey, apiRevision)); + + return router; +} \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/routes/metrics.routes.js b/inventory-server/dashboard/klaviyo-server/routes/metrics.routes.js new file mode 100644 index 0000000..8d36ba7 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/routes/metrics.routes.js @@ -0,0 +1,29 @@ +import express from 'express'; +import { MetricsService } from '../services/metrics.service.js'; + +const router = express.Router(); + +export function createMetricsRoutes(apiKey, apiRevision) { + const metricsService = new MetricsService(apiKey, apiRevision); + + // Get all metrics + router.get('/', async (req, res) => { + try { + console.log('[Metrics Route] Fetching metrics'); + const data = await metricsService.getMetrics(); + console.log('[Metrics Route] Success:', { + count: data.data?.length || 0 + }); + res.json(data); + } catch (error) { + console.error('[Metrics Route] Error:', error); + res.status(500).json({ + status: 'error', + message: error.message, + details: error.response?.data || null + }); + } + }); + + return router; +} \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/routes/reporting.routes.js b/inventory-server/dashboard/klaviyo-server/routes/reporting.routes.js new file mode 100644 index 0000000..c33ad77 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/routes/reporting.routes.js @@ -0,0 +1,29 @@ +import express from 'express'; +import { ReportingService } from '../services/reporting.service.js'; +import { TimeManager } from '../utils/time.utils.js'; + +export function createReportingRouter(apiKey, apiRevision) { + const router = express.Router(); + const reportingService = new ReportingService(apiKey, apiRevision); + const timeManager = new TimeManager(); + + // Get campaign reports by time range + router.get('/campaigns/:timeRange', async (req, res) => { + try { + const { timeRange } = req.params; + const { channel } = req.query; + + const reports = await reportingService.getCampaignReports({ + timeRange, + channel + }); + + res.json(reports); + } catch (error) { + console.error('[ReportingRoutes] Error fetching campaign reports:', error); + res.status(500).json({ error: error.message }); + } + }); + + return router; +} \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/server.js b/inventory-server/dashboard/klaviyo-server/server.js new file mode 100644 index 0000000..5354192 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/server.js @@ -0,0 +1,78 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import rateLimit from 'express-rate-limit'; +import { createApiRouter } from './routes/index.js'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Get directory name in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables +const envPath = path.resolve(__dirname, '.env'); +console.log('[Server] Loading .env file from:', envPath); +dotenv.config({ path: envPath }); + +// Debug environment variables (without exposing sensitive data) +console.log('[Server] Environment variables loaded:', { + REDIS_HOST: process.env.REDIS_HOST || '(not set)', + REDIS_PORT: process.env.REDIS_PORT || '(not set)', + REDIS_USERNAME: process.env.REDIS_USERNAME || '(not set)', + REDIS_PASSWORD: process.env.REDIS_PASSWORD ? '(set)' : '(not set)', + NODE_ENV: process.env.NODE_ENV || '(not set)', +}); + +const app = express(); +const port = process.env.KLAVIYO_PORT || 3004; + +// Rate limiting for reporting endpoints +const reportingLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 10, // limit each IP to 10 requests per windowMs + message: 'Too many requests to reporting endpoint, please try again later', + keyGenerator: (req) => { + // Use a combination of IP and endpoint for more granular control + return `${req.ip}-reporting`; + }, + skip: (req) => { + // Only apply to campaign-values-reports endpoint + return !req.path.includes('campaign-values-reports'); + } +}); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Debug middleware to log all requests +app.use((req, res, next) => { + console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); + next(); +}); + +// Apply rate limiting to reporting endpoints +app.use('/api/klaviyo/reporting', reportingLimiter); + +// Create and mount API routes +const apiRouter = createApiRouter( + process.env.KLAVIYO_API_KEY, + process.env.KLAVIYO_API_REVISION || '2024-02-15' +); +app.use('/api/klaviyo', apiRouter); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ + status: 'error', + message: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? err.message : undefined + }); +}); + +// Start server +app.listen(port, '0.0.0.0', () => { + console.log(`Klaviyo server listening at http://0.0.0.0:${port}`); +}); \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/services/campaigns.service.js b/inventory-server/dashboard/klaviyo-server/services/campaigns.service.js new file mode 100644 index 0000000..38db7c5 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/services/campaigns.service.js @@ -0,0 +1,206 @@ +import fetch from 'node-fetch'; +import { TimeManager } from '../utils/time.utils.js'; +import { RedisService } from './redis.service.js'; + +export class CampaignsService { + constructor(apiKey, apiRevision) { + this.apiKey = apiKey; + this.apiRevision = apiRevision; + this.baseUrl = 'https://a.klaviyo.com/api'; + this.timeManager = new TimeManager(); + this.redisService = new RedisService(); + } + + async getCampaigns(params = {}) { + try { + // Add request debouncing + const requestKey = JSON.stringify(params); + if (this._pendingRequests && this._pendingRequests[requestKey]) { + return this._pendingRequests[requestKey]; + } + + // Try to get from cache first + const cacheKey = this.redisService._getCacheKey('campaigns', params); + let cachedData = null; + try { + cachedData = await this.redisService.get(`${cacheKey}:raw`); + if (cachedData) { + return cachedData; + } + } catch (cacheError) { + console.warn('[CampaignsService] Cache error:', cacheError); + } + + this._pendingRequests = this._pendingRequests || {}; + this._pendingRequests[requestKey] = (async () => { + let allCampaigns = []; + let nextCursor = params.pageCursor; + let pageCount = 0; + + const filter = params.filter || this._buildFilter(params); + + do { + const queryParams = new URLSearchParams(); + if (filter) { + queryParams.append('filter', filter); + } + queryParams.append('sort', params.sort || '-send_time'); + + if (nextCursor) { + queryParams.append('page[cursor]', nextCursor); + } + + const url = `${this.baseUrl}/campaigns?${queryParams.toString()}`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Klaviyo-API-Key ${this.apiKey}`, + 'revision': this.apiRevision + } + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error('[CampaignsService] API Error:', errorData); + throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`); + } + + const responseData = await response.json(); + allCampaigns = allCampaigns.concat(responseData.data || []); + pageCount++; + + nextCursor = responseData.links?.next ? + new URL(responseData.links.next).searchParams.get('page[cursor]') : null; + + if (nextCursor) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } catch (fetchError) { + console.error('[CampaignsService] Fetch error:', fetchError); + throw fetchError; + } + + } while (nextCursor); + + const transformedCampaigns = this._transformCampaigns(allCampaigns); + + const result = { + data: transformedCampaigns, + meta: { + total_count: transformedCampaigns.length, + page_count: pageCount + } + }; + + try { + const ttl = this.redisService._getTTL(params.timeRange); + await this.redisService.set(`${cacheKey}:raw`, result, ttl); + } catch (cacheError) { + console.warn('[CampaignsService] Cache set error:', cacheError); + } + + delete this._pendingRequests[requestKey]; + return result; + })(); + + return await this._pendingRequests[requestKey]; + } catch (error) { + console.error('[CampaignsService] Error fetching campaigns:', error); + throw error; + } + } + + _buildFilter(params) { + const filters = []; + + if (params.startDate && params.endDate) { + const startUtc = this.timeManager.formatForAPI(params.startDate); + const endUtc = this.timeManager.formatForAPI(params.endDate); + + filters.push(`greater-or-equal(send_time,${startUtc})`); + filters.push(`less-than(send_time,${endUtc})`); + } + + if (params.status) { + filters.push(`equals(status,"${params.status}")`); + } + + if (params.customFilters) { + filters.push(...params.customFilters); + } + + return filters.length > 0 ? (filters.length > 1 ? `and(${filters.join(',')})` : filters[0]) : null; + } + + async getCampaignsByTimeRange(timeRange, options = {}) { + const range = this.timeManager.getDateRange(timeRange); + if (!range) { + throw new Error('Invalid time range specified'); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO(), + ...options + }; + + // Try to get from cache first + const cacheKey = this.redisService._getCacheKey('campaigns', params); + let cachedData = null; + try { + cachedData = await this.redisService.get(`${cacheKey}:raw`); + if (cachedData) { + return cachedData; + } + } catch (cacheError) { + console.warn('[CampaignsService] Cache error:', cacheError); + } + + return this.getCampaigns(params); + } + + _transformCampaigns(campaigns) { + if (!Array.isArray(campaigns)) { + console.warn('[CampaignsService] Campaigns is not an array:', campaigns); + return []; + } + + return campaigns.map(campaign => { + try { + const stats = campaign.attributes?.campaign_message?.stats || {}; + + return { + id: campaign.id, + name: campaign.attributes?.name || "Unnamed Campaign", + subject: campaign.attributes?.campaign_message?.subject || "", + send_time: campaign.attributes?.send_time, + stats: { + delivery_rate: stats.delivery_rate || 0, + delivered: stats.delivered || 0, + recipients: stats.recipients || 0, + open_rate: stats.open_rate || 0, + opens_unique: stats.opens_unique || 0, + opens: stats.opens || 0, + clicks_unique: stats.clicks_unique || 0, + click_rate: stats.click_rate || 0, + click_to_open_rate: stats.click_to_open_rate || 0, + conversion_value: stats.conversion_value || 0, + conversion_uniques: stats.conversion_uniques || 0 + } + }; + } catch (error) { + console.error('[CampaignsService] Error transforming campaign:', error, campaign); + return { + id: campaign.id || 'unknown', + name: 'Error Processing Campaign', + stats: {} + }; + } + }); + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/services/events.service.js b/inventory-server/dashboard/klaviyo-server/services/events.service.js new file mode 100644 index 0000000..cfdb8c1 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/services/events.service.js @@ -0,0 +1,2202 @@ +import fetch from 'node-fetch'; +import { TimeManager } from '../utils/time.utils.js'; +import { RedisService } from './redis.service.js'; +import _ from 'lodash'; + +const METRIC_IDS = { + PLACED_ORDER: 'Y8cqcF', + SHIPPED_ORDER: 'VExpdL', + ACCOUNT_CREATED: 'TeeypV', + CANCELED_ORDER: 'YjVMNg', + NEW_BLOG_POST: 'YcxeDr', + PAYMENT_REFUNDED: 'R7XUYh' +}; + +export class EventsService { + constructor(apiKey, apiRevision) { + this.apiKey = apiKey; + this.apiRevision = apiRevision; + this.baseUrl = 'https://a.klaviyo.com/api'; + this.timeManager = new TimeManager(); + this.redisService = new RedisService(); + } + + async getEvents(params = {}) { + try { + // Add request debouncing + const requestKey = JSON.stringify(params); + if (this._pendingRequests && this._pendingRequests[requestKey]) { + return this._pendingRequests[requestKey]; + } + + // Try to get from cache first + const cacheKey = this.redisService._getCacheKey('events', params); + let cachedData = null; + try { + cachedData = await this.redisService.get(`${cacheKey}:raw`); + if (cachedData) { + cachedData.data = this._transformEvents(cachedData.data); + return cachedData; + } + } catch (cacheError) { + console.warn('[EventsService] Cache error:', cacheError); + } + + this._pendingRequests = this._pendingRequests || {}; + this._pendingRequests[requestKey] = (async () => { + let allEvents = []; + let nextCursor = params.pageCursor; + let pageCount = 0; + + const filter = params.filter || this._buildFilter(params); + + do { + const queryParams = new URLSearchParams(); + if (filter) { + queryParams.append('filter', filter); + } + queryParams.append('sort', params.sort || '-datetime'); + + if (nextCursor) { + queryParams.append('page[cursor]', nextCursor); + } + + const url = `${this.baseUrl}/events?${queryParams.toString()}`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Klaviyo-API-Key ${this.apiKey}`, + 'revision': this.apiRevision + } + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error('[EventsService] API Error:', errorData); + throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`); + } + + const responseData = await response.json(); + allEvents = allEvents.concat(responseData.data || []); + pageCount++; + + nextCursor = responseData.links?.next ? + new URL(responseData.links.next).searchParams.get('page[cursor]') : null; + + if (nextCursor) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } catch (fetchError) { + console.error('[EventsService] Fetch error:', fetchError); + throw fetchError; + } + + } while (nextCursor); + + const transformedEvents = this._transformEvents(allEvents); + + const result = { + data: transformedEvents, + meta: { + total_count: transformedEvents.length, + page_count: pageCount + } + }; + + try { + const ttl = this.redisService._getTTL(params.timeRange); + await this.redisService.set(`${cacheKey}:raw`, result, ttl); + } catch (cacheError) { + console.warn('[EventsService] Cache set error:', cacheError); + } + + delete this._pendingRequests[requestKey]; + return result; + })(); + + return await this._pendingRequests[requestKey]; + } catch (error) { + console.error('[EventsService] Error fetching events:', error); + throw error; + } + } + + _buildFilter(params) { + const filters = []; + + if (params.metricId) { + filters.push(`equals(metric_id,"${params.metricId}")`); + } + + if (params.startDate && params.endDate) { + const startUtc = this.timeManager.formatForAPI(params.startDate); + const endUtc = this.timeManager.formatForAPI(params.endDate); + + filters.push(`greater-or-equal(datetime,${startUtc})`); + filters.push(`less-than(datetime,${endUtc})`); + } + + if (params.profileId) { + filters.push(`equals(profile_id,"${params.profileId}")`); + } + + if (params.customFilters) { + filters.push(...params.customFilters); + } + + return filters.length > 0 ? (filters.length > 1 ? `and(${filters.join(',')})` : filters[0]) : null; + } + + async getEventsByTimeRange(timeRange, options = {}) { + const range = this.timeManager.getDateRange(timeRange); + if (!range) { + throw new Error('Invalid time range specified'); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO(), + metricId: options.metricId + }; + + // Try to get from cache first + const cacheKey = this.redisService._getCacheKey('events', params); + let cachedData = null; + try { + cachedData = await this.redisService.get(`${cacheKey}:raw`); + if (cachedData) { + // Transform cached events + cachedData.data = this._transformEvents(cachedData.data); + return cachedData; + } + } catch (cacheError) { + console.warn('[EventsService] Cache error:', cacheError); + // Continue with API request if cache fails + } + + return this.getEvents(params); + } + + async calculatePeriodStats(params = {}) { + try { + // Add request debouncing + const requestKey = JSON.stringify(params); + if (this._pendingStatRequests && this._pendingStatRequests[requestKey]) { + return this._pendingStatRequests[requestKey]; + } + + // Get period dates + let periodStart, periodEnd, prevPeriodStart, prevPeriodEnd; + if (params.startDate && params.endDate) { + periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(params.startDate)); + periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(params.endDate)); + const duration = periodEnd.diff(periodStart); + prevPeriodStart = this.timeManager.getDayStart(periodStart.minus(duration)); + prevPeriodEnd = this.timeManager.getDayEnd(periodStart.minus({ milliseconds: 1 })); + } else if (params.timeRange) { + const range = this.timeManager.getDateRange(params.timeRange); + periodStart = range.start; + periodEnd = range.end; + const prevRange = this.timeManager.getPreviousPeriod(params.timeRange); + prevPeriodStart = prevRange.start; + prevPeriodEnd = prevRange.end; + } + + // Load both current and previous period data + const [orderData, shippedData, refundData, canceledData, prevPeriodData] = await Promise.all([ + this.getEvents({ + ...params, + metricId: METRIC_IDS.PLACED_ORDER + }), + this.getEvents({ + ...params, + metricId: METRIC_IDS.SHIPPED_ORDER + }), + this.getEvents({ + ...params, + metricId: METRIC_IDS.PAYMENT_REFUNDED + }), + this.getEvents({ + ...params, + metricId: METRIC_IDS.CANCELED_ORDER + }), + this.getEvents({ + // Only pass through non-date related params for previous period + ..._.omit(params, ['timeRange', 'startDate', 'endDate']), + metricId: METRIC_IDS.PLACED_ORDER, + startDate: prevPeriodStart.toISO(), + endDate: prevPeriodEnd.toISO() + }) + ]); + + // Add debug logging + console.log('[EventsService] Previous period request:', { + params: _.omit(params, ['timeRange', 'startDate', 'endDate']), + dates: { + start: prevPeriodStart.toISO(), + end: prevPeriodEnd.toISO() + }, + responseLength: prevPeriodData?.data?.length + }); + + // Transform all data + const transformedOrders = this._transformEvents(orderData.data); + const transformedShipped = this._transformEvents(shippedData.data); + const transformedRefunds = this._transformEvents(refundData.data); + const transformedCanceled = this._transformEvents(canceledData.data); + const transformedPrevPeriod = this._transformEvents(prevPeriodData.data); + + // Calculate previous period stats + const prevPeriodRevenue = transformedPrevPeriod.reduce((sum, order) => { + const props = order.event_properties || {}; + return sum + Number(props.TotalAmount || 0); + }, 0); + + const prevPeriodOrders = transformedPrevPeriod.length; + const prevPeriodAOV = prevPeriodOrders > 0 ? prevPeriodRevenue / prevPeriodOrders : 0; + + // Calculate stats with all data available + const stats = { + orderCount: 0, + revenue: 0, + averageOrderValue: 0, + itemCount: 0, + prevPeriodRevenue, + projectedRevenue: 0, + periodProgress: 0, + dailyData: [], + products: { + list: [], + total: 0, + status: { + backordered: 0, + inStock: 0, + outOfStock: 0, + preorder: 0 + } + }, + shipping: { + shippedCount: 0, + locations: { + byState: [], + byCountry: [] + }, + methods: {}, + methodPercentages: {}, + totalRevenue: 0, + averageShipTime: 0, + totalShipTime: 0 + }, + refunds: { + total: 0, + count: 0, + reasons: {}, + items: [], + averageAmount: 0 + }, + canceledOrders: { + total: 0, + count: 0, + reasons: {}, + items: [], + averageAmount: 0 + }, + orderTypes: { + preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } + }, + brands: { + total: 0, + list: [], + topBrands: [], + totalRevenue: 0, + averageOrderValue: 0 + }, + categories: { + total: 0, + list: [], + topCategories: [], + totalRevenue: 0, + averageOrderValue: 0 + }, + hourlyOrders: Array(24).fill(0), + peakOrderHour: null, + bestRevenueDay: null, + orderValueRange: { + largest: 0, + smallest: 0, + largestOrderId: null, + smallestOrderId: null, + distribution: { + under25: { count: 0, total: 0 }, + under50: { count: 0, total: 0 }, + under100: { count: 0, total: 0 }, + under200: { count: 0, total: 0 }, + over200: { count: 0, total: 0 } + } + } + }; + + // Calculate period progress + if (periodStart && periodEnd) { + const totalDuration = periodEnd.diff(periodStart); + const elapsedDuration = this.timeManager.getNow().diff(periodStart); + stats.periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100)); + } + + // Process orders + const brandMap = new Map(); + const categoryMap = new Map(); + const dailyOrderCounts = {}; + const dailyStats = new Map(); + + // Track best day stats + let bestDay = { + date: null, + displayDate: null, + amount: 0, + orderCount: 0 + }; + + // Initialize daily stats for the entire date range + if (periodStart && periodEnd) { + let currentDate = periodStart; + while (currentDate <= periodEnd) { + const dateKey = currentDate.toFormat('yyyy-MM-dd'); + dailyStats.set(dateKey, { + date: currentDate.toISO(), // ISO format for chart library + timestamp: dateKey, + revenue: 0, + orders: 0, + itemCount: 0, + averageOrderValue: 0, + averageItemsPerOrder: 0, + hourlyOrders: Array(24).fill(0), + refunds: { total: 0, count: 0, reasons: {}, items: [] }, + canceledOrders: { total: 0, count: 0, reasons: {}, items: [] }, + orderTypes: { + preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } + }, + orderValueRange: { + largest: 0, + smallest: 0, + largestOrderId: null, + smallestOrderId: null, + distribution: { + under25: { count: 0, total: 0 }, + under50: { count: 0, total: 0 }, + under100: { count: 0, total: 0 }, + under200: { count: 0, total: 0 }, + over200: { count: 0, total: 0 } + } + } + }); + currentDate = this.timeManager.getDayStart(currentDate.plus({ days: 1 })); + } + } + + // Track peak hour stats + let maxHourCount = 0; + let peakHour = 0; + + for (const order of transformedOrders) { + const props = order.event_properties || {}; + const items = props.Items || []; + const totalAmount = Number(props.TotalAmount || 0); + const datetime = this.timeManager.toDateTime(order.attributes?.datetime); + const orderId = props.OrderId; + + // Update order counts and revenue + stats.orderCount++; + stats.revenue += totalAmount; + stats.itemCount += items.length; + + // Calculate running AOV + stats.averageOrderValue = stats.revenue / stats.orderCount; + + // Track daily stats + if (datetime) { + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + const hourOfDay = datetime.hour; + + // Initialize stats for this date if it doesn't exist + if (!dailyStats.has(dateKey)) { + dailyStats.set(dateKey, { + date: datetime.toISO(), // ISO format for chart library + timestamp: dateKey, + revenue: 0, + orders: 0, + itemCount: 0, + averageOrderValue: 0, + averageItemsPerOrder: 0, + hourlyOrders: Array(24).fill(0), + refunds: { total: 0, count: 0, reasons: {}, items: [] }, + canceledOrders: { total: 0, count: 0, reasons: {}, items: [] }, + orderTypes: { + preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } + }, + orderValueRange: { + largest: 0, + smallest: 0, + largestOrderId: null, + smallestOrderId: null, + distribution: { + under25: { count: 0, total: 0 }, + under50: { count: 0, total: 0 }, + under100: { count: 0, total: 0 }, + under200: { count: 0, total: 0 }, + over200: { count: 0, total: 0 } + } + } + }); + } + + const dayStats = dailyStats.get(dateKey); + + // Update daily stats + dayStats.revenue += totalAmount; + dayStats.orders++; + dayStats.itemCount += items.length; + dayStats.hourlyOrders[hourOfDay]++; + dayStats.averageOrderValue = dayStats.revenue / dayStats.orders; + dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.orders; + + // Track best day + if (dayStats.revenue > bestDay.amount) { + bestDay = { + date: dateKey, + displayDate: dayStart.toFormat('LLL d, yyyy'), + amount: dayStats.revenue, + orderCount: dayStats.orders + }; + } + + // Track daily order value range + if (totalAmount > dayStats.orderValueRange.largest) { + dayStats.orderValueRange.largest = totalAmount; + dayStats.orderValueRange.largestOrderId = orderId; + } + if (totalAmount > 0) { + if (dayStats.orderValueRange.smallest === 0 || totalAmount < dayStats.orderValueRange.smallest) { + dayStats.orderValueRange.smallest = totalAmount; + dayStats.orderValueRange.smallestOrderId = orderId; + } + + // Track order value distribution + if (totalAmount < 25) { + dayStats.orderValueRange.distribution.under25.count++; + dayStats.orderValueRange.distribution.under25.total += totalAmount; + } else if (totalAmount < 50) { + dayStats.orderValueRange.distribution.under50.count++; + dayStats.orderValueRange.distribution.under50.total += totalAmount; + } else if (totalAmount < 100) { + dayStats.orderValueRange.distribution.under100.count++; + dayStats.orderValueRange.distribution.under100.total += totalAmount; + } else if (totalAmount < 200) { + dayStats.orderValueRange.distribution.under200.count++; + dayStats.orderValueRange.distribution.under200.total += totalAmount; + } else { + dayStats.orderValueRange.distribution.over200.count++; + dayStats.orderValueRange.distribution.over200.total += totalAmount; + } + } + + // Track order types + if (props.HasPreorder) { + dayStats.orderTypes.preOrders.count++; + dayStats.orderTypes.preOrders.value += totalAmount; + dayStats.orderTypes.preOrders.percentage = (dayStats.orderTypes.preOrders.count / dayStats.orders) * 100; + dayStats.orderTypes.preOrders.averageValue = dayStats.orderTypes.preOrders.value / dayStats.orderTypes.preOrders.count; + } + if (props.LocalPickup) { + dayStats.orderTypes.localPickup.count++; + dayStats.orderTypes.localPickup.value += totalAmount; + dayStats.orderTypes.localPickup.percentage = (dayStats.orderTypes.localPickup.count / dayStats.orders) * 100; + dayStats.orderTypes.localPickup.averageValue = dayStats.orderTypes.localPickup.value / dayStats.orderTypes.localPickup.count; + } + if (props.IsOnHold) { + dayStats.orderTypes.heldItems.count++; + dayStats.orderTypes.heldItems.value += totalAmount; + dayStats.orderTypes.heldItems.percentage = (dayStats.orderTypes.heldItems.count / dayStats.orders) * 100; + dayStats.orderTypes.heldItems.averageValue = dayStats.orderTypes.heldItems.value / dayStats.orderTypes.heldItems.count; + } + if (props.HasDigiItem) { + dayStats.orderTypes.digital.count++; + dayStats.orderTypes.digital.value += totalAmount; + dayStats.orderTypes.digital.percentage = (dayStats.orderTypes.digital.count / dayStats.orders) * 100; + dayStats.orderTypes.digital.averageValue = dayStats.orderTypes.digital.value / dayStats.orderTypes.digital.count; + } + if (props.HasDigitalGC) { + dayStats.orderTypes.giftCard.count++; + dayStats.orderTypes.giftCard.value += totalAmount; + dayStats.orderTypes.giftCard.percentage = (dayStats.orderTypes.giftCard.count / dayStats.orders) * 100; + dayStats.orderTypes.giftCard.averageValue = dayStats.orderTypes.giftCard.value / dayStats.orderTypes.giftCard.count; + } + + // Update hourly stats for peak hour calculation + if (dayStats.hourlyOrders[hourOfDay] > maxHourCount) { + maxHourCount = dayStats.hourlyOrders[hourOfDay]; + peakHour = hourOfDay; + } + + dailyStats.set(dateKey, dayStats); + } + + // Track order value range + if (totalAmount > stats.orderValueRange.largest) { + stats.orderValueRange.largest = totalAmount; + stats.orderValueRange.largestOrderId = orderId; + } + if (totalAmount > 0) { + if (stats.orderValueRange.smallest === 0 || totalAmount < stats.orderValueRange.smallest) { + stats.orderValueRange.smallest = totalAmount; + stats.orderValueRange.smallestOrderId = orderId; + } + + // Track order value distribution + if (totalAmount < 25) { + stats.orderValueRange.distribution.under25.count++; + stats.orderValueRange.distribution.under25.total += totalAmount; + } else if (totalAmount < 50) { + stats.orderValueRange.distribution.under50.count++; + stats.orderValueRange.distribution.under50.total += totalAmount; + } else if (totalAmount < 100) { + stats.orderValueRange.distribution.under100.count++; + stats.orderValueRange.distribution.under100.total += totalAmount; + } else if (totalAmount < 200) { + stats.orderValueRange.distribution.under200.count++; + stats.orderValueRange.distribution.under200.total += totalAmount; + } else { + stats.orderValueRange.distribution.over200.count++; + stats.orderValueRange.distribution.over200.total += totalAmount; + } + } + + // Track order types with values + if (props.HasPreorder) { + stats.orderTypes.preOrders.count++; + stats.orderTypes.preOrders.value += totalAmount; + stats.orderTypes.preOrders.items.push({ orderId, amount: totalAmount, items }); + stats.orderTypes.preOrders.percentage = (stats.orderTypes.preOrders.count / stats.orderCount) * 100; + stats.orderTypes.preOrders.averageValue = stats.orderTypes.preOrders.value / stats.orderTypes.preOrders.count; + } + if (props.LocalPickup) { + stats.orderTypes.localPickup.count++; + stats.orderTypes.localPickup.value += totalAmount; + stats.orderTypes.localPickup.items.push({ orderId, amount: totalAmount, items }); + stats.orderTypes.localPickup.percentage = (stats.orderTypes.localPickup.count / stats.orderCount) * 100; + stats.orderTypes.localPickup.averageValue = stats.orderTypes.localPickup.value / stats.orderTypes.localPickup.count; + } + if (props.IsOnHold) { + stats.orderTypes.heldItems.count++; + stats.orderTypes.heldItems.value += totalAmount; + stats.orderTypes.heldItems.items.push({ orderId, amount: totalAmount, items }); + stats.orderTypes.heldItems.percentage = (stats.orderTypes.heldItems.count / stats.orderCount) * 100; + stats.orderTypes.heldItems.averageValue = stats.orderTypes.heldItems.value / stats.orderTypes.heldItems.count; + } + if (props.HasDigiItem) { + stats.orderTypes.digital.count++; + stats.orderTypes.digital.value += totalAmount; + stats.orderTypes.digital.items.push({ orderId, amount: totalAmount, items }); + stats.orderTypes.digital.percentage = (stats.orderTypes.digital.count / stats.orderCount) * 100; + stats.orderTypes.digital.averageValue = stats.orderTypes.digital.value / stats.orderTypes.digital.count; + } + if (props.HasDigitalGC) { + stats.orderTypes.giftCard.count++; + stats.orderTypes.giftCard.value += totalAmount; + stats.orderTypes.giftCard.items.push({ orderId, amount: totalAmount, items }); + stats.orderTypes.giftCard.percentage = (stats.orderTypes.giftCard.count / stats.orderCount) * 100; + stats.orderTypes.giftCard.averageValue = stats.orderTypes.giftCard.value / stats.orderTypes.giftCard.count; + } + + // Track hourly and daily stats + if (datetime) { + const hour = datetime.hour; + stats.hourlyOrders[hour]++; + } + + // Process products and related data + for (const item of items) { + const productId = item.ProductID; + const quantity = Number(item.Quantity || item.QuantityOrdered || 1); + const revenue = Number(item.RowTotal || (item.ItemPrice * quantity) || 0); + + // Track item status + switch(item.ItemStatus?.toLowerCase()) { + case 'backordered': + stats.products.status.backordered++; + break; + case 'out of stock': + stats.products.status.outOfStock++; + break; + case 'preorder': + stats.products.status.preorder++; + break; + default: + stats.products.status.inStock++; + } + + // Update product stats + const existingProduct = stats.products.list.find(p => p.id === productId); + if (existingProduct) { + existingProduct.totalQuantity += quantity; + existingProduct.totalRevenue += revenue; + existingProduct.orderCount++; + existingProduct.orders.add(orderId); + } else { + stats.products.list.push({ + id: productId, + sku: item.SKU, + name: item.ProductName, + brand: item.Brand, + price: Number(item.ItemPrice || 0), + ImgThumb: item.ImgThumb, + totalQuantity: quantity, + totalRevenue: revenue, + orderCount: 1, + orders: new Set([orderId]), + categories: item.Categories || [], + status: item.ItemStatus || 'In Stock' + }); + } + + // Update brand stats + if (item.Brand) { + const brand = brandMap.get(item.Brand) || { + name: item.Brand, + quantity: 0, + revenue: 0, + products: new Set() + }; + brand.quantity += quantity; + brand.revenue += revenue; + brand.products.add(productId); + brandMap.set(item.Brand, brand); + } + + // Update category stats + if (item.Categories) { + for (const category of item.Categories) { + const categoryStats = categoryMap.get(category) || { + name: category, + quantity: 0, + revenue: 0, + products: new Set() + }; + categoryStats.quantity += quantity; + categoryStats.revenue += revenue; + categoryStats.products.add(productId); + categoryMap.set(category, categoryStats); + } + } + } + } + + // After processing all orders + if (stats.orderCount > 0) { + stats.averageOrderValue = stats.revenue / stats.orderCount; + stats.averageItemsPerOrder = stats.itemCount / stats.orderCount; + } + + // Calculate projected revenue for incomplete periods + if (periodStart && periodEnd) { + const totalDuration = periodEnd.diff(periodStart); + const elapsedDuration = this.timeManager.getNow().diff(periodStart); + const periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100)); + + if (periodProgress > 0 && periodProgress < 100) { + stats.projectedRevenue = (stats.revenue / (periodProgress / 100)); + } else { + stats.projectedRevenue = stats.revenue; + } + stats.periodProgress = periodProgress; + } + + // Calculate trend data only for revenue + if (prevPeriodRevenue > 0) { + stats.trend = { + revenue: ((stats.revenue - prevPeriodRevenue) / prevPeriodRevenue) * 100 + }; + } + + // Process shipped orders with better formatting + const shippingMethodMap = new Map(); + const locationMap = new Map(); + const stateMap = new Map(); + const countryMap = new Map(); + + for (const shipped of transformedShipped) { + const props = shipped.event_properties || {}; + stats.shipping.shippedCount++; + + // Track shipping methods + const method = props.ShipMethod || props.ShippingMethod || 'Unknown'; + const currentMethodCount = shippingMethodMap.get(method) || 0; + shippingMethodMap.set(method, currentMethodCount + 1); + + // Track locations by state and country + const state = props.ShippingState?.trim() || 'Unknown State'; + const country = props.ShippingCountry?.trim() || 'Unknown Country'; + + // Track unique locations + const locationKey = `${state}-${country}`; + locationMap.set(locationKey, true); + + // Track by state + const stateStats = stateMap.get(state) || { count: 0, country }; + stateStats.count++; + stateMap.set(state, stateStats); + + // Track by country + const countryStats = countryMap.get(country) || { count: 0, states: new Set() }; + countryStats.count++; + countryStats.states.add(state); + countryMap.set(country, countryStats); + } + + // Format shipping methods + stats.shipping.methods = Object.fromEntries(shippingMethodMap); + stats.shipping.methodPercentages = {}; + stats.shipping.methodStats = []; + shippingMethodMap.forEach((count, method) => { + const percentage = (count / stats.shipping.shippedCount) * 100; + stats.shipping.methodPercentages[method] = percentage; + stats.shipping.methodStats.push({ + name: method, + value: count, + percentage + }); + }); + stats.shipping.methodStats.sort((a, b) => b.value - a.value); + + // Format locations by state and country + stats.shipping.locations = { + total: locationMap.size, + byState: Array.from(stateMap.entries()) + .map(([state, data]) => ({ + state, + country: data.country, + count: data.count, + percentage: (data.count / stats.shipping.shippedCount) * 100 + })) + .sort((a, b) => b.count - a.count), + byCountry: Array.from(countryMap.entries()) + .map(([country, data]) => ({ + country, + count: data.count, + states: Array.from(data.states), + percentage: (data.count / stats.shipping.shippedCount) * 100 + })) + .sort((a, b) => b.count - a.count) + }; + + // Process refunds with more detail + for (const refund of transformedRefunds) { + const props = refund.event_properties || {}; + const amount = Number(props.PaymentAmount || 0); + const reason = props.CancelReason || props.OrderMessage || 'No reason provided'; + const datetime = this.timeManager.toDateTime(refund.attributes?.datetime); + const orderId = props.OrderId || props.FromOrder; + + stats.refunds.total += amount; + stats.refunds.count++; + stats.refunds.reasons[reason] = (stats.refunds.reasons[reason] || 0) + 1; + stats.refunds.items.push({ + orderId, + amount, + reason, + datetime: datetime?.toISO() + }); + stats.refunds.averageAmount = stats.refunds.total / stats.refunds.count; + + // Track daily refunds + if (datetime) { + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + // Initialize stats for this date if it doesn't exist + if (!dailyStats.has(dateKey)) { + dailyStats.set(dateKey, { + date: datetime.toISO(), // ISO format for chart library + timestamp: dateKey, + revenue: 0, + orders: 0, + itemCount: 0, + averageOrderValue: 0, + averageItemsPerOrder: 0, + hourlyOrders: Array(24).fill(0), + refunds: { total: 0, count: 0, reasons: {}, items: [] }, + canceledOrders: { total: 0, count: 0, reasons: {}, items: [] }, + orderTypes: { + preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } + }, + orderValueRange: { + largest: 0, + smallest: 0, + largestOrderId: null, + smallestOrderId: null, + distribution: { + under25: { count: 0, total: 0 }, + under50: { count: 0, total: 0 }, + under100: { count: 0, total: 0 }, + under200: { count: 0, total: 0 }, + over200: { count: 0, total: 0 } + } + } + }); + } + const dayStats = dailyStats.get(dateKey); + dayStats.refunds.total += amount; + dayStats.refunds.count++; + dayStats.refunds.reasons[reason] = (dayStats.refunds.reasons[reason] || 0) + 1; + dailyStats.set(dateKey, dayStats); + } + } + + // Process canceled orders with more detail + for (const canceled of transformedCanceled) { + const props = canceled.event_properties || {}; + const amount = Number(props.TotalAmount || 0); + const reason = props.CancelReason || props.OrderMessage || 'No reason provided'; + const datetime = this.timeManager.toDateTime(canceled.attributes?.datetime); + const orderId = props.OrderId || props.FromOrder; + + stats.canceledOrders = stats.canceledOrders || { total: 0, count: 0, reasons: {}, items: [], averageAmount: 0 }; + stats.canceledOrders.total += amount; + stats.canceledOrders.count++; + stats.canceledOrders.reasons[reason] = (stats.canceledOrders.reasons[reason] || 0) + 1; + stats.canceledOrders.items.push({ + orderId, + amount, + reason, + datetime: datetime?.toISO() + }); + stats.canceledOrders.averageAmount = stats.canceledOrders.total / stats.canceledOrders.count; + + // Track daily cancellations + if (datetime) { + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + // Initialize stats for this date if it doesn't exist + if (!dailyStats.has(dateKey)) { + dailyStats.set(dateKey, { + date: datetime.toISO(), // ISO format for chart library + timestamp: dateKey, + revenue: 0, + orders: 0, + itemCount: 0, + averageOrderValue: 0, + averageItemsPerOrder: 0, + hourlyOrders: Array(24).fill(0), + refunds: { total: 0, count: 0, reasons: {}, items: [] }, + canceledOrders: { total: 0, count: 0, reasons: {}, items: [] }, + orderTypes: { + preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } + }, + orderValueRange: { + largest: 0, + smallest: 0, + largestOrderId: null, + smallestOrderId: null, + distribution: { + under25: { count: 0, total: 0 }, + under50: { count: 0, total: 0 }, + under100: { count: 0, total: 0 }, + under200: { count: 0, total: 0 }, + over200: { count: 0, total: 0 } + } + } + }); + } + const dayStats = dailyStats.get(dateKey); + dayStats.canceledOrders.total += amount; + dayStats.canceledOrders.count++; + dayStats.canceledOrders.reasons[reason] = (dayStats.canceledOrders.reasons[reason] || 0) + 1; + dailyStats.set(dateKey, dayStats); + } + } + + // Set peak hour stats + if (maxHourCount > 0) { + stats.peakOrderHour = { + hour: peakHour, + count: maxHourCount, + displayHour: this._formatHour(peakHour), + hourData: stats.hourlyOrders.map((count, hour) => ({ + hour, + displayHour: this._formatHour(hour), + count, + percentage: (count / stats.orderCount) * 100 + })) + }; + } + + // Set best day stats + if (bestDay.date) { + stats.bestRevenueDay = { + ...bestDay, + dialogType: 'revenue', // Add dialog type for frontend + dialogTitle: 'Revenue Details' // Add dialog title for frontend + }; + } + + // Process products with status tracking + stats.products.list = stats.products.list.map(product => ({ + ...product, + averageOrderValue: product.totalRevenue / product.orderCount, + orders: Array.from(product.orders) + })); + stats.products.list.sort((a, b) => b.totalRevenue - a.totalRevenue); + stats.products.total = stats.products.list.length; + + // Format brands with more detail and period-specific data + stats.brands.list = Array.from(brandMap.values()) + .map(brand => { + const brandStats = { + name: brand.name, + count: brand.quantity, + revenue: brand.revenue, + productCount: brand.products.size, + percentage: (brand.revenue / stats.revenue) * 100, // Calculate percentage based on period revenue + averageOrderValue: brand.revenue / brand.quantity, + products: Array.from(brand.products) + }; + // Add display formatting for charts + brandStats.tooltipLabel = `${brand.name}\nRevenue: $${brand.revenue.toFixed(2)}\nItems: ${brand.quantity}`; + return brandStats; + }) + .sort((a, b) => b.revenue - a.revenue); + + stats.brands.topBrands = stats.brands.list.slice(0, 10); + stats.brands.totalRevenue = stats.revenue; // Use period revenue + stats.brands.averageOrderValue = stats.revenue / stats.itemCount; + stats.brands.total = stats.brands.list.length; + + // Format categories with more detail and period-specific data + stats.categories.list = Array.from(categoryMap.values()) + .map(category => { + const categoryStats = { + name: category.name, + count: category.quantity, + revenue: category.revenue, + productCount: category.products.size, + percentage: (category.revenue / stats.revenue) * 100, // Calculate percentage based on period revenue + averageOrderValue: category.revenue / category.quantity, + products: Array.from(category.products) + }; + // Add display formatting for charts + categoryStats.tooltipLabel = `${category.name}\nRevenue: $${category.revenue.toFixed(2)}\nItems: ${category.quantity}`; + return categoryStats; + }) + .sort((a, b) => b.revenue - a.revenue); + + stats.categories.topCategories = stats.categories.list.slice(0, 10); + stats.categories.totalRevenue = stats.revenue; // Use period revenue + stats.categories.averageOrderValue = stats.revenue / stats.itemCount; + stats.categories.total = stats.categories.list.length; + + // Remove pie chart labels and create a key + stats.brands.key = stats.brands.list.map(brand => ({ + name: brand.name, + color: this._getColorForBrand(brand.name) + })); + stats.categories.key = stats.categories.list.map(category => ({ + name: category.name, + color: this._getColorForCategory(category.name) + })); + + // Set peak hour stats with proper formatting + if (maxHourCount > 0) { + stats.peakOrderHour = { + hour: peakHour, + count: maxHourCount, + displayHour: this._formatHour(peakHour), + hourData: stats.hourlyOrders.map((count, hour) => ({ + hour, + displayHour: this._formatHour(hour), + count, + percentage: (count / stats.orderCount) * 100 + })) + }; + } + + // Set best day stats with link to revenue dialog + if (bestDay.date) { + stats.bestRevenueDay = { + ...bestDay, + dialogType: 'revenue', // Add dialog type for frontend + dialogTitle: 'Revenue Details' // Add dialog title for frontend + }; + } + + // Add daily stats for order types + const orderTypeStats = { + preOrders: { dailyData: [] }, + localPickup: { dailyData: [] }, + heldItems: { dailyData: [] } + }; + + // Process daily stats for order types + stats.dailyData.forEach(day => { + // Pre-orders daily data + orderTypeStats.preOrders.dailyData.push({ + date: day.date, + count: day.orderTypes.preOrders.count, + value: day.orderTypes.preOrders.value, + percentage: day.orderTypes.preOrders.percentage, + totalOrders: day.orders + }); + + // Local pickup daily data + orderTypeStats.localPickup.dailyData.push({ + date: day.date, + count: day.orderTypes.localPickup.count, + value: day.orderTypes.localPickup.value, + percentage: day.orderTypes.localPickup.percentage, + totalOrders: day.orders + }); + + // Held items daily data + orderTypeStats.heldItems.dailyData.push({ + date: day.date, + count: day.orderTypes.heldItems.count, + value: day.orderTypes.heldItems.value, + percentage: day.orderTypes.heldItems.percentage, + totalOrders: day.orders + }); + }); + + // Add order type stats to main stats object + stats.orderTypes.preOrders.dailyData = orderTypeStats.preOrders.dailyData; + stats.orderTypes.localPickup.dailyData = orderTypeStats.localPickup.dailyData; + stats.orderTypes.heldItems.dailyData = orderTypeStats.heldItems.dailyData; + + // Convert daily stats to array and sort + stats.dailyData = Array.from(dailyStats.values()) + .sort((a, b) => a.date.localeCompare(b.date)) + .map(day => ({ + date: day.date, + timestamp: day.timestamp, + revenue: Number(day.revenue || 0), + orders: Number(day.orders || 0), + itemCount: Number(day.itemCount || 0), + averageOrderValue: Number(day.averageOrderValue || 0), + averageItemsPerOrder: Number(day.averageItemsPerOrder || 0), + hourlyOrders: day.hourlyOrders || Array(24).fill(0), + refunds: { + total: Number(day.refunds?.total || 0), + count: Number(day.refunds?.count || 0), + reasons: day.refunds?.reasons || {} + }, + canceledOrders: { + total: Number(day.canceledOrders?.total || 0), + count: Number(day.canceledOrders?.count || 0), + reasons: day.canceledOrders?.reasons || {} + }, + orderTypes: { + preOrders: { + count: Number(day.orderTypes?.preOrders?.count || 0), + value: Number(day.orderTypes?.preOrders?.value || 0), + percentage: Number(day.orderTypes?.preOrders?.percentage || 0), + averageValue: Number(day.orderTypes?.preOrders?.averageValue || 0) + }, + localPickup: { + count: Number(day.orderTypes?.localPickup?.count || 0), + value: Number(day.orderTypes?.localPickup?.value || 0), + percentage: Number(day.orderTypes?.localPickup?.percentage || 0), + averageValue: Number(day.orderTypes?.localPickup?.averageValue || 0) + }, + heldItems: { + count: Number(day.orderTypes?.heldItems?.count || 0), + value: Number(day.orderTypes?.heldItems?.value || 0), + percentage: Number(day.orderTypes?.heldItems?.percentage || 0), + averageValue: Number(day.orderTypes?.heldItems?.averageValue || 0) + }, + digital: { + count: Number(day.orderTypes?.digital?.count || 0), + value: Number(day.orderTypes?.digital?.value || 0), + percentage: Number(day.orderTypes?.digital?.percentage || 0), + averageValue: Number(day.orderTypes?.digital?.averageValue || 0) + }, + giftCard: { + count: Number(day.orderTypes?.giftCard?.count || 0), + value: Number(day.orderTypes?.giftCard?.value || 0), + percentage: Number(day.orderTypes?.giftCard?.percentage || 0), + averageValue: Number(day.orderTypes?.giftCard?.averageValue || 0) + } + } + })); + + // Set totals + stats.brands.total = stats.brands.list.length; + stats.categories.total = stats.categories.list.length; + + return stats; + } catch (error) { + console.error('[EventsService] Error calculating period stats:', error); + throw error; + } + } + + _getEmptyStats() { + return { + orderCount: 0, + revenue: 0, + averageOrderValue: 0, + itemCount: 0, + prevPeriodRevenue: 0, + projectedRevenue: 0, + periodProgress: 0, + dailyData: [], + products: { + list: [], + total: 0, + status: { + backordered: 0, + inStock: 0, + outOfStock: 0, + preorder: 0 + } + }, + shipping: { + shippedCount: 0, + locations: { + total: 0, + byState: [], + byCountry: [] + }, + methods: {}, + methodStats: [], + methodPercentages: {} + }, + refunds: { + total: 0, + count: 0, + reasons: {}, + items: [], + averageAmount: 0 + }, + canceledOrders: { + total: 0, + count: 0, + reasons: {}, + items: [], + averageAmount: 0 + }, + orderTypes: { + preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, + giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } + }, + brands: { + total: 0, + list: [], + topBrands: [], + totalRevenue: 0, + averageOrderValue: 0 + }, + categories: { + total: 0, + list: [], + topCategories: [], + totalRevenue: 0, + averageOrderValue: 0 + }, + hourlyOrders: Array(24).fill(0), + peakOrderHour: null, + bestRevenueDay: null, + orderValueRange: { + largest: 0, + smallest: 0, + largestOrderId: null, + smallestOrderId: null, + distribution: { + under25: { count: 0, total: 0 }, + under50: { count: 0, total: 0 }, + under100: { count: 0, total: 0 }, + under200: { count: 0, total: 0 }, + over200: { count: 0, total: 0 } + } + } + }; + } + + async getMultiMetricEvents(params = {}) { + try { + const { timeRange, startDate, endDate, metricIds } = params; + const metrics = metricIds || Object.values(METRIC_IDS); + + // Get period dates using TimeManager to respect 1 AM day start + let periodStart, periodEnd; + if (startDate && endDate) { + periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(startDate)); + periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(endDate)); + } else if (timeRange) { + const range = this.timeManager.getDateRange(timeRange); + periodStart = range.start; + periodEnd = range.end; + } + + // Try to get from cache first + const cacheKey = this.redisService._getCacheKey('events', params); + let cachedData = null; + try { + cachedData = await this.redisService.get(`${cacheKey}:feed`); + if (cachedData) { + return cachedData; + } + } catch (cacheError) { + console.warn('[EventsService] Cache error:', cacheError); + } + + // Fetch events for all specified metrics + const eventPromises = metrics.map(metricId => + this.getEvents({ + startDate: periodStart.toISO(), + endDate: periodEnd.toISO(), + timeRange, + metricId, + sort: '-datetime' + }) + ); + + const results = await Promise.all(eventPromises); + + // Transform and flatten the events into a single array + const allEvents = []; + results.forEach((result) => { + if (result && Array.isArray(result.data)) { + allEvents.push(...result.data); + } + }); + + // Sort all events by datetime in descending order + allEvents.sort((a, b) => { + const dateA = new Date(a.attributes?.datetime || 0); + const dateB = new Date(b.attributes?.datetime || 0); + return dateB - dateA; + }); + + const result = { + data: allEvents, + meta: { + total_count: allEvents.length + } + }; + + // Cache the result + try { + const ttl = this.redisService._getTTL(timeRange); + await this.redisService.set(`${cacheKey}:feed`, result, ttl); + } catch (cacheError) { + console.warn('[EventsService] Cache set error:', cacheError); + } + + return result; + } catch (error) { + console.error('[EventsService] Error in batch metrics:', error); + throw error; + } + } + + _transformEvents(events) { + if (!Array.isArray(events)) { + console.warn('[EventsService] Events is not an array:', events); + return []; + } + + return events.map(event => { + try { + // Extract metric ID from all possible locations + const metricId = event.relationships?.metric?.data?.id || + event.attributes?.metric?.id || + event.attributes?.metric_id; + + // Extract properties from all possible locations + const rawProps = event.attributes?.event_properties || {}; + + // Only log for shipped orders and only show relevant fields + if (event.relationships?.metric?.data?.id === METRIC_IDS.SHIPPED_ORDER) { + console.log('[EventsService] Shipped Order:', { + orderId: rawProps.OrderId, + shippedBy: rawProps.ShippedBy, + datetime: event.attributes?.datetime + }); + } + + // Normalize shipping data + const shippingData = { + ShippingName: rawProps.ShippingName, + ShippingStreet1: rawProps.ShippingStreet1, + ShippingStreet2: rawProps.ShippingStreet2, + ShippingCity: rawProps.ShippingCity, + ShippingState: rawProps.ShippingState, + ShippingZip: rawProps.ShippingZip, + ShippingCountry: rawProps.ShippingCountry, + ShipMethod: rawProps.ShipMethod, + TrackingNumber: rawProps.TrackingNumber, + ShippedBy: rawProps.ShippedBy + }; + + // Normalize payment data + const paymentData = { + method: rawProps.PaymentMethod, + name: rawProps.PaymentName, + amount: Number(rawProps.PaymentAmount || 0) + }; + + // Normalize order flags + const orderFlags = { + type: rawProps.OrderType || 'standard', + hasPreorder: Boolean(rawProps.HasPreorder), + localPickup: Boolean(rawProps.LocalPickup), + isOnHold: Boolean(rawProps.IsOnHold), + hasDigiItem: Boolean(rawProps.HasDigiItem), + hasNotions: Boolean(rawProps.HasNotions), + hasDigitalGC: Boolean(rawProps.HasDigitalGC), + stillOwes: Boolean(rawProps.StillOwes) + }; + + // Normalize refund/cancel data + const refundData = { + reason: rawProps.CancelReason, + message: rawProps.CancelMessage, + orderMessage: rawProps.OrderMessage + }; + + // Transform items + const items = this._transformItems(rawProps.Items || []); + + // Calculate totals + const totalAmount = Number(rawProps.TotalAmount || rawProps.PaymentAmount || rawProps.value || 0); + const itemCount = items.reduce((sum, item) => sum + Number(item.Quantity || item.QuantityOrdered || 1), 0); + + const transformed = { + id: event.id, + type: event.type, + metric_id: metricId, + attributes: { + ...event.attributes, + datetime: event.attributes?.datetime, + value: event.attributes?.value, + metric: { + ...event.attributes?.metric, + id: metricId + } + }, + relationships: event.relationships, + event_properties: { + ...rawProps, // Include all original properties + Items: items, // Override with transformed items + TotalAmount: totalAmount, + ItemCount: itemCount + } + }; + + return transformed; + } catch (error) { + console.error('[EventsService] Error transforming event:', error, event); + // Return a minimal valid event structure + return { + id: event.id || 'unknown', + type: event.type || 'unknown', + metric_id: event.relationships?.metric?.data?.id || 'unknown', + attributes: event.attributes || {}, + event_properties: {} + }; + } + }).filter(Boolean); // Remove any null/undefined events + } + + _transformItems(items) { + if (!Array.isArray(items)) { + console.warn('[EventsService] Items is not an array:', items); + return []; + } + + return items.map(item => { + try { + const quantity = Number(item.Quantity || item.QuantityOrdered || item.quantity || item.quantity_ordered || 1); + const price = Number(item.ItemPrice || item.item_price || item.price || 0); + const rowTotal = Number(item.RowTotal || item.row_total || (price * quantity) || 0); + + const transformed = { + // Basic item information + ProductID: item.ProductID || item.product_id || item.id, + ProductName: item.ProductName || item.product_name || item.name, + SKU: item.SKU || item.sku, + Brand: item.Brand || item.brand, + Categories: Array.isArray(item.Categories) ? item.Categories : + Array.isArray(item.categories) ? item.categories : [], + + // Pricing + ItemPrice: price, + RowTotal: rowTotal, + + // Quantities + Quantity: quantity, + QuantityOrdered: quantity, + QuantitySent: Number(item.QuantitySent || item.quantity_sent || 0), + QuantityBackordered: Number(item.QuantityBackordered || item.quantity_backordered || 0), + + // Status and images + ItemStatus: item.ItemStatus || item.item_status || item.status || 'In Stock', + ImgThumb: item.ImgThumb || item.img_thumb || item.thumbnail, + + // Additional properties + IsPreorder: Boolean(item.IsPreorder || item.is_preorder || item.preorder), + IsDigital: Boolean(item.IsDigital || item.is_digital || item.digital), + IsGiftCard: Boolean(item.IsGiftCard || item.is_gift_card || item.gift_card), + + // Original properties (for backward compatibility) + ...item + }; + + return transformed; + } catch (error) { + console.error('[EventsService] Error transforming item:', error, item); + // Return a minimal valid item structure + return { + ProductID: item.ProductID || item.product_id || 'unknown', + ProductName: item.ProductName || item.product_name || 'Unknown Product', + Quantity: 1, + ItemPrice: 0, + RowTotal: 0 + }; + } + }); + } + + _formatHour(hour) { + if (hour === 0) return "12:00 AM"; + if (hour === 12) return "12:00 PM"; + if (hour > 12) return `${hour - 12}:00 PM`; + return `${hour}:00 AM`; + } + + async calculateDetailedStats(params = {}) { + try { + const { metric, daily = false } = params; + console.log('[EventsService] Request params:', params); + + // Get period dates using TimeManager to respect 1 AM day start + let periodStart, periodEnd, prevPeriodStart, prevPeriodEnd; + if (params.startDate && params.endDate) { + periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(params.startDate)); + periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(params.endDate)); + const duration = periodEnd.diff(periodStart); + prevPeriodStart = this.timeManager.getDayStart(periodStart.minus(duration)); + prevPeriodEnd = this.timeManager.getDayEnd(periodStart.minus({ milliseconds: 1 })); + } else if (params.timeRange) { + const range = this.timeManager.getDateRange(params.timeRange); + periodStart = range.start; + periodEnd = range.end; + const prevRange = this.timeManager.getPreviousPeriod(params.timeRange); + prevPeriodStart = prevRange.start; + prevPeriodEnd = prevRange.end; + } + + // For order range, we need to process all orders with their value distribution + if (metric === 'order_range') { + const [currentEvents] = await Promise.all([ + this.getEvents({ + ...params, + startDate: periodStart.toISO(), + endDate: periodEnd.toISO(), + metricId: METRIC_IDS.PLACED_ORDER + }) + ]); + + // Transform events + const transformedEvents = this._transformEvents(currentEvents.data || []); + console.log(`[EventsService] Processing ${transformedEvents.length} orders for order range`); + + // Initialize daily stats map with all dates in range + const dailyStats = new Map(); + let currentDate = periodStart; + while (currentDate <= periodEnd) { + const dateKey = currentDate.toFormat('yyyy-MM-dd'); + dailyStats.set(dateKey, { + date: currentDate.toISO(), + timestamp: dateKey, + orders: 0, + averageOrderValue: 0, + orderValueRange: { + largest: 0, + smallest: 0, + largestOrderId: null, + smallestOrderId: null + }, + orderValueDistribution: [ + { min: 0, max: 25, count: 0, total: 0 }, + { min: 25, max: 50, count: 0, total: 0 }, + { min: 50, max: 100, count: 0, total: 0 }, + { min: 100, max: 200, count: 0, total: 0 }, + { min: 200, max: 'Infinity', count: 0, total: 0 } + ] + }); + currentDate = currentDate.plus({ days: 1 }); + } + + // Process events + for (const event of transformedEvents) { + const datetime = this.timeManager.toDateTime(event.attributes?.datetime); + if (!datetime) continue; + + const props = event.event_properties || {}; + const totalAmount = Number(props.TotalAmount || 0); + const orderId = props.OrderId; + + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + + if (dailyStats.has(dateKey)) { + const dayStats = dailyStats.get(dateKey); + dayStats.orders++; + + // Update order value range + if (totalAmount > dayStats.orderValueRange.largest) { + dayStats.orderValueRange.largest = totalAmount; + dayStats.orderValueRange.largestOrderId = orderId; + } + if (totalAmount > 0 && (dayStats.orderValueRange.smallest === 0 || totalAmount < dayStats.orderValueRange.smallest)) { + dayStats.orderValueRange.smallest = totalAmount; + dayStats.orderValueRange.smallestOrderId = orderId; + } + + // Update distribution + if (totalAmount < 25) { + dayStats.orderValueDistribution[0].count++; + dayStats.orderValueDistribution[0].total += totalAmount; + } else if (totalAmount < 50) { + dayStats.orderValueDistribution[1].count++; + dayStats.orderValueDistribution[1].total += totalAmount; + } else if (totalAmount < 100) { + dayStats.orderValueDistribution[2].count++; + dayStats.orderValueDistribution[2].total += totalAmount; + } else if (totalAmount < 200) { + dayStats.orderValueDistribution[3].count++; + dayStats.orderValueDistribution[3].total += totalAmount; + } else { + dayStats.orderValueDistribution[4].count++; + dayStats.orderValueDistribution[4].total += totalAmount; + } + + dayStats.averageOrderValue = dayStats.orderValueDistribution.reduce((sum, range) => sum + range.total, 0) / dayStats.orders; + dailyStats.set(dateKey, dayStats); + } + } + + // Convert to array and sort by date + const stats = Array.from(dailyStats.values()) + .sort((a, b) => a.date.localeCompare(b.date)); + + return stats; + } + + // For refunds and cancellations, we need to fetch those specific events + if (metric === 'refunds' || metric === 'cancellations') { + const [currentEvents] = await Promise.all([ + this.getEvents({ + ...params, + startDate: periodStart.toISO(), + endDate: periodEnd.toISO(), + metricId: metric === 'refunds' ? METRIC_IDS.PAYMENT_REFUNDED : METRIC_IDS.CANCELED_ORDER + }) + ]); + + // Transform events + const transformedEvents = this._transformEvents(currentEvents.data || []); + console.log(`[EventsService] Processing ${transformedEvents.length} ${metric}`); + + // Initialize daily stats map with all dates in range using TimeManager's day start + const dailyStats = new Map(); + let currentDate = periodStart; + while (currentDate <= periodEnd) { + const dateKey = currentDate.toFormat('yyyy-MM-dd'); + dailyStats.set(dateKey, { + date: currentDate.toISO(), + timestamp: dateKey, + total: 0, + count: 0, + reasons: {}, + items: [] + }); + currentDate = this.timeManager.getDayStart(currentDate.plus({ days: 1 })); + } + + // Aggregate all reasons and items for the entire period + const periodStats = { + total: 0, + count: 0, + reasons: {}, + items: [] + }; + + // Process current period events + for (const event of transformedEvents) { + const datetime = this.timeManager.toDateTime(event.attributes?.datetime); + if (!datetime) continue; + + const props = event.event_properties || {}; + const amount = Number(metric === 'refunds' ? props.PaymentAmount : props.TotalAmount || 0); + const reason = props.CancelReason || props.OrderMessage || 'No reason provided'; + const orderId = props.OrderId || props.FromOrder; + + const item = { + orderId, + amount, + reason, + datetime: datetime.toISO() + }; + + // Always update period totals for events within the period + periodStats.total += amount; + periodStats.count++; + periodStats.reasons[reason] = (periodStats.reasons[reason] || 0) + 1; + periodStats.items.push(item); + + // Get the day start for this event's time + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + + // Update daily stats if we have this day in our map + if (dailyStats.has(dateKey)) { + const dayStats = dailyStats.get(dateKey); + dayStats.total += amount; + dayStats.count++; + dayStats.reasons[reason] = (dayStats.reasons[reason] || 0) + 1; + dayStats.items.push(item); + dailyStats.set(dateKey, dayStats); + } + } + + console.log(`[EventsService] Period stats for ${metric}:`, { + total: periodStats.total, + count: periodStats.count, + reasonCount: Object.keys(periodStats.reasons).length, + itemCount: periodStats.items.length + }); + + // Convert to array and sort by date + const stats = Array.from(dailyStats.values()) + .sort((a, b) => a.date.localeCompare(b.date)) + .map(day => ({ + ...day, + [metric === 'refunds' ? 'refunds' : 'canceledOrders']: { + total: day.total, + count: day.count, + reasons: periodStats.reasons, // Use period-wide reasons for each day + items: day.items, + periodTotal: periodStats.total, + periodCount: periodStats.count, + periodReasons: periodStats.reasons, + periodItems: periodStats.items + } + })); + + return stats; + } + + // For other metrics, continue with existing logic + const [currentResponse, prevResponse] = await Promise.all([ + this.getEvents({ + ...params, + startDate: periodStart.toISO(), + endDate: periodEnd.toISO(), + metricId: METRIC_IDS.PLACED_ORDER + }), + this.getEvents({ + ..._.omit(params, ['timeRange', 'startDate', 'endDate']), + startDate: prevPeriodStart.toISO(), + endDate: prevPeriodEnd.toISO(), + metricId: METRIC_IDS.PLACED_ORDER + }) + ]); + + // Transform events + const currentEvents = this._transformEvents(currentResponse.data || []); + const prevEvents = this._transformEvents(prevResponse.data || []); + + // Initialize daily stats map with all dates in range using TimeManager's day start + const dailyStats = new Map(); + let currentDate = periodStart; + while (currentDate <= periodEnd) { + const dateKey = currentDate.toFormat('yyyy-MM-dd'); + dailyStats.set(dateKey, { + date: currentDate.toISO(), + timestamp: dateKey, + revenue: 0, + orders: 0, + itemCount: 0, + count: 0, + value: 0, + percentage: 0, + totalOrders: 0, + prevValue: 0, + prevOrders: 0, + prevItemCount: 0, + prevCount: 0, + prevPercentage: 0, + averageOrderValue: 0, + averageItemsPerOrder: 0, + prevAvgOrderValue: 0 + }); + currentDate = this.timeManager.getDayStart(currentDate.plus({ days: 1 })); + } + + // First pass: Count total orders per day using TimeManager's day boundaries + for (const event of currentEvents) { + const datetime = this.timeManager.toDateTime(event.attributes?.datetime); + if (!datetime) continue; + + // Get the day start for this event's time to ensure proper day assignment + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + if (!dailyStats.has(dateKey)) continue; + + const dayStats = dailyStats.get(dateKey); + dayStats.orders++; + dailyStats.set(dateKey, dayStats); + } + + // Second pass: Process filtered orders + const filterEvents = (events) => { + switch (metric) { + case 'pre_orders': + return events.filter(event => Boolean(event.event_properties?.HasPreorder)); + case 'local_pickup': + return events.filter(event => Boolean(event.event_properties?.LocalPickup)); + case 'on_hold': + return events.filter(event => Boolean(event.event_properties?.IsOnHold)); + default: + return events; + } + }; + + const filteredCurrentEvents = filterEvents(currentEvents); + + // Process current period filtered events using TimeManager's day boundaries + for (const event of filteredCurrentEvents) { + const datetime = this.timeManager.toDateTime(event.attributes?.datetime); + if (!datetime) continue; + + // Get the day start for this event's time + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + if (!dailyStats.has(dateKey)) continue; + + const dayStats = dailyStats.get(dateKey); + const props = event.event_properties || {}; + const totalAmount = Number(props.TotalAmount || 0); + const items = props.Items || []; + + dayStats.count++; + dayStats.value += totalAmount; + dayStats.revenue = dayStats.value; + dayStats.itemCount += items.length; + dayStats.percentage = (dayStats.count / dayStats.orders) * 100; + dayStats.averageOrderValue = dayStats.value / dayStats.count; + dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.count; + + dailyStats.set(dateKey, dayStats); + } + + // Initialize and process previous period stats using TimeManager's day boundaries + const prevDailyStats = new Map(); + let prevDate = prevPeriodStart; + while (prevDate <= prevPeriodEnd) { + const dateKey = prevDate.toFormat('yyyy-MM-dd'); + prevDailyStats.set(dateKey, { + date: prevDate.toISO(), + timestamp: dateKey, + orders: 0, + count: 0, + value: 0, + percentage: 0 + }); + prevDate = this.timeManager.getDayStart(prevDate.plus({ days: 1 })); + } + + // First pass for previous period: Count total orders + for (const event of prevEvents) { + const datetime = this.timeManager.toDateTime(event.attributes?.datetime); + if (!datetime) continue; + + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + if (!prevDailyStats.has(dateKey)) continue; + + const dayStats = prevDailyStats.get(dateKey); + dayStats.orders++; + prevDailyStats.set(dateKey, dayStats); + } + + // Second pass for previous period: Process filtered orders + const filteredPrevEvents = filterEvents(prevEvents); + for (const event of filteredPrevEvents) { + const datetime = this.timeManager.toDateTime(event.attributes?.datetime); + if (!datetime) continue; + + const dayStart = this.timeManager.getDayStart(datetime); + const dateKey = dayStart.toFormat('yyyy-MM-dd'); + if (!prevDailyStats.has(dateKey)) continue; + + const dayStats = prevDailyStats.get(dateKey); + const props = event.event_properties || {}; + const totalAmount = Number(props.TotalAmount || 0); + + dayStats.count++; + dayStats.value += totalAmount; + dayStats.percentage = (dayStats.count / dayStats.orders) * 100; + + prevDailyStats.set(dateKey, dayStats); + } + + // Map previous period data to current period days + const prevPeriodDays = Array.from(prevDailyStats.values()).sort((a, b) => a.date.localeCompare(b.date)); + const currentPeriodDays = Array.from(dailyStats.values()).sort((a, b) => a.date.localeCompare(b.date)); + + // Map the data using array indices + for (let i = 0; i < currentPeriodDays.length && i < prevPeriodDays.length; i++) { + const currentDayStats = currentPeriodDays[i]; + const prevDayStats = prevPeriodDays[i]; + + if (prevDayStats && currentDayStats) { + const dayStats = dailyStats.get(currentDayStats.timestamp); + if (dayStats) { + dayStats.prevValue = prevDayStats.value; + dayStats.prevRevenue = prevDayStats.value; + dayStats.prevCount = prevDayStats.count; + dayStats.prevOrders = prevDayStats.orders; + dayStats.prevPercentage = prevDayStats.percentage; + dayStats.prevAvgOrderValue = prevDayStats.count > 0 ? prevDayStats.value / prevDayStats.count : 0; + dailyStats.set(currentDayStats.timestamp, dayStats); + } + } + } + + // Convert to array and sort by date + const stats = Array.from(dailyStats.values()) + .sort((a, b) => a.date.localeCompare(b.date)) + .map(day => ({ + ...day, + revenue: Number(day.revenue || day.value || 0), + orders: Number(day.orders || 0), + itemCount: Number(day.itemCount || 0), + count: Number(day.count || 0), + value: Number(day.value || 0), + percentage: Number(day.percentage || 0), + averageOrderValue: Number(day.averageOrderValue || 0), + averageItemsPerOrder: Number(day.averageItemsPerOrder || 0), + prevRevenue: Number(day.prevRevenue || day.prevValue || 0), + prevValue: Number(day.prevValue || 0), + prevCount: Number(day.prevCount || 0), + prevOrders: Number(day.prevOrders || 0), + prevPercentage: Number(day.prevPercentage || 0), + prevAvgOrderValue: Number(day.prevAvgOrderValue || 0) + })); + + return stats; + } catch (error) { + console.error('[EventsService] Error calculating detailed stats:', error); + throw error; + } + } + + _getColorForBrand(brandName) { + // Generate a consistent color based on the brand name + let hash = 0; + for (let i = 0; i < brandName.length; i++) { + hash = brandName.charCodeAt(i) + ((hash << 5) - hash); + } + + // Use HSL to ensure colors are visually distinct and pleasing + const hue = Math.abs(hash % 360); + return `hsl(${hue}, 70%, 50%)`; + } + + _getColorForCategory(categoryName) { + // Generate a consistent color based on the category name + let hash = 0; + for (let i = 0; i < categoryName.length; i++) { + hash = categoryName.charCodeAt(i) + ((hash << 5) - hash); + } + + // Use HSL with different saturation/lightness than brands + const hue = Math.abs(hash % 360); + return `hsl(${hue}, 60%, 60%)`; + } + + async getBatchMetrics(params = {}) { + try { + const { timeRange, startDate, endDate, metrics = [] } = params; + + // Create a map of all metric requests + const metricPromises = metrics.map(metric => { + switch(metric) { + case 'orders': + return this.getEvents({ + ...params, + metricId: METRIC_IDS.PLACED_ORDER + }); + case 'revenue': + return this.getEvents({ + ...params, + metricId: METRIC_IDS.PLACED_ORDER, + property: 'TotalAmount' + }); + case 'refunds': + return this.getEvents({ + ...params, + metricId: METRIC_IDS.PAYMENT_REFUNDED + }); + case 'cancellations': + return this.getEvents({ + ...params, + metricId: METRIC_IDS.CANCELED_ORDER + }); + case 'shipping': + return this.getEvents({ + ...params, + metricId: METRIC_IDS.SHIPPED_ORDER + }); + default: + return Promise.resolve(null); + } + }); + + // Execute all promises in parallel + const results = await Promise.all(metricPromises); + + // Transform results into a keyed object + const batchResults = {}; + metrics.forEach((metric, index) => { + batchResults[metric] = results[index]; + }); + + return batchResults; + } catch (error) { + console.error('[EventsService] Error in batch metrics:', error); + throw error; + } + } + + async calculateSmartProjection(params = {}) { + try { + const { timeRange, startDate, endDate } = params; + + // Get current period dates + let periodStart, periodEnd; + if (startDate && endDate) { + periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(startDate)); + periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(endDate)); + } else if (timeRange) { + const range = this.timeManager.getDateRange(timeRange); + periodStart = range.start; + periodEnd = range.end; + } + + // Get the same day of week from the last 4 weeks for pattern matching + const historicalPeriods = []; + let historicalStart = periodStart.minus({ weeks: 4 }); + + for (let i = 0; i < 4; i++) { + historicalPeriods.push({ + start: historicalStart.plus({ weeks: i }), + end: historicalStart.plus({ weeks: i + 1 }).minus({ milliseconds: 1 }) + }); + } + + // Fetch current period data + const currentEvents = await this.getEvents({ + startDate: periodStart.toISO(), + endDate: periodEnd.toISO(), + metricId: METRIC_IDS.PLACED_ORDER + }); + + // Fetch historical data for pattern matching + const historicalPromises = historicalPeriods.map(period => + this.getEvents({ + startDate: period.start.toISO(), + endDate: period.end.toISO(), + metricId: METRIC_IDS.PLACED_ORDER + }) + ); + + const historicalResults = await Promise.all(historicalPromises); + + // Process current period data + const currentData = this._transformEvents(currentEvents.data || []); + const currentRevenue = currentData.reduce((sum, event) => { + const props = event.event_properties || {}; + return sum + Number(props.TotalAmount || 0); + }, 0); + + // Build hourly patterns from historical data + const hourlyPatterns = Array(24).fill(0).map(() => ({ + count: 0, + revenue: 0, + percentage: 0 + })); + + let totalHistoricalRevenue = 0; + let totalHistoricalOrders = 0; + + historicalResults.forEach(result => { + const events = this._transformEvents(result.data || []); + events.forEach(event => { + const datetime = this.timeManager.toDateTime(event.attributes?.datetime); + if (!datetime) return; + + const hour = datetime.hour; + const props = event.event_properties || {}; + const amount = Number(props.TotalAmount || 0); + + hourlyPatterns[hour].count++; + hourlyPatterns[hour].revenue += amount; + totalHistoricalRevenue += amount; + totalHistoricalOrders++; + }); + }); + + // Calculate percentages + hourlyPatterns.forEach(pattern => { + pattern.percentage = totalHistoricalRevenue > 0 ? + (pattern.revenue / totalHistoricalRevenue) * 100 : 0; + }); + + // Get current hour in the period's timezone + const now = this.timeManager.getNow(); + const currentHour = now.hour; + const currentMinute = now.minute; + + // Handle the 12-1 AM edge case + const isInEdgeCase = currentHour < this.timeManager.dayStartHour; + const adjustedCurrentHour = isInEdgeCase ? currentHour + 24 : currentHour; + const adjustedDayStartHour = this.timeManager.dayStartHour; + + // Calculate how much of the current hour has passed (0-1) + const hourProgress = currentMinute / 60; + + // Calculate how much of the expected daily revenue we've seen so far + let expectedPercentageSeen = 0; + let totalDayPercentage = 0; + + // First, calculate total percentage for a full day + for (let i = 0; i < 24; i++) { + totalDayPercentage += hourlyPatterns[i].percentage; + } + + if (isInEdgeCase) { + // If we're between 12-1 AM, we want to use almost the full day's percentage + // since we're at the end of the previous day + expectedPercentageSeen = totalDayPercentage; + // Subtract the remaining portion of the current hour + expectedPercentageSeen -= hourlyPatterns[currentHour].percentage * (1 - hourProgress); + } else { + // Normal case - add up percentages from day start to current hour + for (let i = adjustedDayStartHour; i < adjustedCurrentHour; i++) { + const hourIndex = i % 24; + expectedPercentageSeen += hourlyPatterns[hourIndex].percentage; + } + // Add partial current hour + expectedPercentageSeen += hourlyPatterns[currentHour].percentage * hourProgress; + } + + // Calculate projection based on patterns + let projectedRevenue = 0; + if (expectedPercentageSeen > 0) { + projectedRevenue = (currentRevenue / (expectedPercentageSeen / totalDayPercentage)); + } + + // Calculate confidence score (0-1) based on: + // 1. How much historical data we have + // 2. How consistent the patterns are + // 3. How far through the period we are + const patternConsistency = this._calculatePatternConsistency(hourlyPatterns); + + // Calculate period progress considering the 1 AM day start + const totalDuration = periodEnd.diff(periodStart); + const elapsedDuration = now.diff(periodStart); + let periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100)); + + // Adjust period progress for the 12-1 AM edge case + if (isInEdgeCase) { + // If we're between 12-1 AM, we're actually at the end of the previous day + periodProgress = Math.min(100, Math.max(0, ((24 - adjustedDayStartHour + currentHour) / 24) * 100)); + } + + const historicalDataAmount = Math.min(totalHistoricalOrders / 1000, 1); + + const confidence = ( + (patternConsistency * 0.4) + + (periodProgress / 100 * 0.4) + + (historicalDataAmount * 0.2) + ); + + // Return both the simple and pattern-based projections with metadata + return { + currentRevenue, + projectedRevenue, + confidence, + metadata: { + periodProgress, + patternConsistency, + historicalOrders: totalHistoricalOrders, + hourlyPatterns, + expectedPercentageSeen, + totalDayPercentage, + currentHour, + currentMinute, + isInEdgeCase, + adjustedCurrentHour + } + }; + } catch (error) { + console.error('[EventsService] Error calculating smart projection:', error); + throw error; + } + } + + _calculatePatternConsistency(hourlyPatterns) { + // Calculate the standard deviation of the percentage distribution + const mean = hourlyPatterns.reduce((sum, pattern) => sum + pattern.percentage, 0) / 24; + const variance = hourlyPatterns.reduce((sum, pattern) => { + const diff = pattern.percentage - mean; + return sum + (diff * diff); + }, 0) / 24; + const stdDev = Math.sqrt(variance); + + // Normalize to a 0-1 scale where lower standard deviation means higher consistency + // Using a sigmoid function to normalize + const normalizedConsistency = 1 / (1 + Math.exp(stdDev / 10)); + return normalizedConsistency; + } +} diff --git a/inventory-server/dashboard/klaviyo-server/services/metrics.service.js b/inventory-server/dashboard/klaviyo-server/services/metrics.service.js new file mode 100644 index 0000000..57feeca --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/services/metrics.service.js @@ -0,0 +1,38 @@ +import fetch from 'node-fetch'; + +export class MetricsService { + constructor(apiKey, apiRevision) { + this.apiKey = apiKey; + this.apiRevision = apiRevision; + this.baseUrl = 'https://a.klaviyo.com/api'; + } + async getMetrics() { + try { + const response = await fetch(`${this.baseUrl}/metrics/`, { + headers: { + 'Authorization': `Klaviyo-API-Key ${this.apiKey}`, + 'revision': this.apiRevision, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error('[MetricsService] API Error:', errorData); + throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + // Sort the results by name before returning + if (data.data) { + data.data.sort((a, b) => a.attributes.name.localeCompare(b.attributes.name)); + } + + return data; + } catch (error) { + console.error('[MetricsService] Error fetching metrics:', error); + throw error; + } + } +} diff --git a/inventory-server/dashboard/klaviyo-server/services/redis.service.js b/inventory-server/dashboard/klaviyo-server/services/redis.service.js new file mode 100644 index 0000000..884cca6 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/services/redis.service.js @@ -0,0 +1,262 @@ +import Redis from 'ioredis'; +import { TimeManager } from '../utils/time.utils.js'; +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Get directory name in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables again (redundant but safe) +const envPath = path.resolve(__dirname, '../.env'); +console.log('[RedisService] Loading .env file from:', envPath); +dotenv.config({ path: envPath }); + +export class RedisService { + constructor() { + this.timeManager = new TimeManager(); + this.DEFAULT_TTL = 5 * 60; // 5 minutes default TTL + this.isConnected = false; + this._initializeRedis(); + } + + _initializeRedis() { + try { + // Debug: Print all environment variables we're looking for + console.log('[RedisService] Environment variables state:', { + REDIS_HOST: process.env.REDIS_HOST ? '(set)' : '(not set)', + REDIS_PORT: process.env.REDIS_PORT ? '(set)' : '(not set)', + REDIS_USERNAME: process.env.REDIS_USERNAME ? '(set)' : '(not set)', + REDIS_PASSWORD: process.env.REDIS_PASSWORD ? '(set)' : '(not set)', + }); + + // Log Redis configuration (without password) + const host = process.env.REDIS_HOST || 'localhost'; + const port = parseInt(process.env.REDIS_PORT) || 6379; + const username = process.env.REDIS_USERNAME || 'default'; + const password = process.env.REDIS_PASSWORD; + + console.log('[RedisService] Initializing Redis with config:', { + host, + port, + username, + hasPassword: !!password + }); + + const config = { + host, + port, + username, + retryStrategy: (times) => { + const delay = Math.min(times * 50, 2000); + return delay; + }, + maxRetriesPerRequest: 3, + enableReadyCheck: true, + connectTimeout: 10000, + showFriendlyErrorStack: true, + retryUnfulfilled: true, + maxRetryAttempts: 5 + }; + + // Only add password if it exists + if (password) { + console.log('[RedisService] Adding password to config'); + config.password = password; + } else { + console.warn('[RedisService] No Redis password found in environment variables!'); + } + + this.client = new Redis(config); + + // Handle connection events + this.client.on('connect', () => { + console.log('[RedisService] Connected to Redis'); + this.isConnected = true; + }); + + this.client.on('ready', () => { + console.log('[RedisService] Redis is ready'); + this.isConnected = true; + }); + + this.client.on('error', (err) => { + console.error('[RedisService] Redis error:', err); + this.isConnected = false; + // Log more details about the error + if (err.code === 'WRONGPASS') { + console.error('[RedisService] Authentication failed. Please check your Redis password.'); + } + }); + + this.client.on('close', () => { + console.log('[RedisService] Redis connection closed'); + this.isConnected = false; + }); + + this.client.on('reconnecting', (params) => { + console.log('[RedisService] Reconnecting to Redis:', params); + }); + + } catch (error) { + console.error('[RedisService] Error initializing Redis:', error); + this.isConnected = false; + } + } + + async get(key) { + if (!this.isConnected) { + return null; + } + + try { + const data = await this.client.get(key); + return data ? JSON.parse(data) : null; + } catch (error) { + console.error('[RedisService] Error getting data:', error); + return null; + } + } + + async set(key, data, ttl = this.DEFAULT_TTL) { + if (!this.isConnected) { + return; + } + + try { + await this.client.setex(key, ttl, JSON.stringify(data)); + } catch (error) { + console.error('[RedisService] Error setting data:', error); + } + } + +// Helper to generate cache keys +_getCacheKey(type, params = {}) { + const { + timeRange, + startDate, + endDate, + metricId, + metric, + daily, + cacheKey, + isPreviousPeriod, + customFilters + } = params; + + let key = `klaviyo:${type}`; + + // Handle "stats:details" for daily or metric-based keys + if (type === 'stats:details') { + // Add metric to key + key += `:${metric || 'all'}`; + + // Add daily flag if present + if (daily) { + key += ':daily'; + } + + // Add custom filters hash if present + if (customFilters?.length) { + const filterHash = customFilters.join('').replace(/[^a-zA-Z0-9]/g, ''); + key += `:${filterHash}`; + } + } + + // If a specific cache key is provided, use it (highest priority) + if (cacheKey) { + key += `:${cacheKey}`; + } + // Otherwise, build a default cache key + else if (timeRange) { + key += `:${timeRange}`; + if (metricId) { + key += `:${metricId}`; + } + if (isPreviousPeriod) { + key += ':prev'; + } + } else if (startDate && endDate) { + // For custom date ranges, include both dates in the key + key += `:custom:${startDate}:${endDate}`; + if (metricId) { + key += `:${metricId}`; + } + if (isPreviousPeriod) { + key += ':prev'; + } + } + + // Add order type to key if present + if (['pre_orders', 'local_pickup', 'on_hold'].includes(metric)) { + key += `:${metric}`; + } + + return key; +} + + + // Get TTL based on time range + _getTTL(timeRange) { + const TTL_MAP = { + 'today': 2 * 60, // 2 minutes + 'yesterday': 30 * 60, // 30 minutes + 'thisWeek': 5 * 60, // 5 minutes + 'lastWeek': 60 * 60, // 1 hour + 'thisMonth': 10 * 60, // 10 minutes + 'lastMonth': 2 * 60 * 60, // 2 hours + 'last7days': 5 * 60, // 5 minutes + 'last30days': 15 * 60, // 15 minutes + 'custom': 15 * 60 // 15 minutes + }; + return TTL_MAP[timeRange] || this.DEFAULT_TTL; + } + + async getEventData(type, params) { + if (!this.isConnected) { + return null; + } + + try { + const baseKey = this._getCacheKey('events', params); + const data = await this.get(`${baseKey}:${type}`); + return data; + } catch (error) { + console.error('[RedisService] Error getting event data:', error); + return null; + } + } + + async cacheEventData(type, params, data) { + if (!this.isConnected) { + return; + } + + try { + const ttl = this._getTTL(params.timeRange); + const baseKey = this._getCacheKey('events', params); + + // Cache raw event data + await this.set(`${baseKey}:${type}`, data, ttl); + } catch (error) { + console.error('[RedisService] Error caching event data:', error); + } + } + + async clearCache(params = {}) { + if (!this.isConnected) { + return; + } + + try { + const pattern = this._getCacheKey('events', params) + '*'; + const keys = await this.client.keys(pattern); + if (keys.length > 0) { + await this.client.del(...keys); + } + } catch (error) { + console.error('[RedisService] Error clearing cache:', error); + } + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/services/reporting.service.js b/inventory-server/dashboard/klaviyo-server/services/reporting.service.js new file mode 100644 index 0000000..2332de3 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/services/reporting.service.js @@ -0,0 +1,254 @@ +import fetch from 'node-fetch'; +import { TimeManager } from '../utils/time.utils.js'; +import { RedisService } from './redis.service.js'; + +const METRIC_IDS = { + PLACED_ORDER: 'Y8cqcF' +}; + +export class ReportingService { + constructor(apiKey, apiRevision) { + this.apiKey = apiKey; + this.apiRevision = apiRevision; + this.baseUrl = 'https://a.klaviyo.com/api'; + this.timeManager = new TimeManager(); + this.redisService = new RedisService(); + this._pendingReportRequest = null; + } + + async getCampaignReports(params = {}) { + try { + // Check if there's a pending request + if (this._pendingReportRequest) { + console.log('[ReportingService] Using pending campaign report request'); + return this._pendingReportRequest; + } + + // Try to get from cache first + const cacheKey = this.redisService._getCacheKey('campaign_reports', params); + let cachedData = null; + try { + cachedData = await this.redisService.get(`${cacheKey}:raw`); + if (cachedData) { + console.log('[ReportingService] Using cached campaign report data'); + return cachedData; + } + } catch (cacheError) { + console.warn('[ReportingService] Cache error:', cacheError); + } + + // Create new request promise + this._pendingReportRequest = (async () => { + console.log('[ReportingService] Fetching fresh campaign report data'); + + const range = this.timeManager.getDateRange(params.timeRange || 'last30days'); + + // Determine which channels to fetch based on params + const channelsToFetch = params.channel === 'all' || !params.channel + ? ['email', 'sms'] + : [params.channel]; + + const allResults = []; + + // Fetch each channel + for (const channel of channelsToFetch) { + const payload = { + data: { + type: "campaign-values-report", + attributes: { + timeframe: { + start: range.start.toISO(), + end: range.end.toISO() + }, + statistics: [ + "delivery_rate", + "delivered", + "recipients", + "open_rate", + "opens_unique", + "opens", + "click_rate", + "clicks_unique", + "click_to_open_rate", + "conversion_value", + "conversion_uniques" + ], + conversion_metric_id: METRIC_IDS.PLACED_ORDER, + filter: `equals(send_channel,"${channel}")` + } + } + }; + + const response = await fetch(`${this.baseUrl}/campaign-values-reports`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Klaviyo-API-Key ${this.apiKey}`, + 'revision': this.apiRevision + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error('[ReportingService] API Error:', errorData); + throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`); + } + + const reportData = await response.json(); + console.log(`[ReportingService] Raw ${channel} report data:`, JSON.stringify(reportData, null, 2)); + + // Get campaign IDs from the report + const campaignIds = reportData.data?.attributes?.results?.map(result => + result.groupings?.campaign_id + ).filter(Boolean) || []; + + if (campaignIds.length > 0) { + // Get campaign details including send time and subject lines + const campaignDetails = await this.getCampaignDetails(campaignIds); + + // Process results for this channel + const channelResults = reportData.data.attributes.results.map(result => { + const campaignId = result.groupings.campaign_id; + const details = campaignDetails.find(detail => detail.id === campaignId); + + return { + id: campaignId, + name: details.attributes.name, + subject: details.attributes.subject, + send_time: details.attributes.send_time, + channel: channel, // Use the channel we're currently processing + stats: { + delivery_rate: result.statistics.delivery_rate, + delivered: result.statistics.delivered, + recipients: result.statistics.recipients, + open_rate: result.statistics.open_rate, + opens_unique: result.statistics.opens_unique, + opens: result.statistics.opens, + click_rate: result.statistics.click_rate, + clicks_unique: result.statistics.clicks_unique, + click_to_open_rate: result.statistics.click_to_open_rate, + conversion_value: result.statistics.conversion_value, + conversion_uniques: result.statistics.conversion_uniques + } + }; + }); + + allResults.push(...channelResults); + } + } + + // Sort all results by date + const enrichedData = { + data: allResults.sort((a, b) => { + const dateA = new Date(a.send_time); + const dateB = new Date(b.send_time); + return dateB - dateA; // Sort by date descending + }) + }; + + console.log('[ReportingService] Enriched data:', JSON.stringify(enrichedData, null, 2)); + + // Cache the enriched response for 10 minutes + try { + await this.redisService.set(`${cacheKey}:raw`, enrichedData, 600); + } catch (cacheError) { + console.warn('[ReportingService] Cache set error:', cacheError); + } + + return enrichedData; + })(); + + const result = await this._pendingReportRequest; + this._pendingReportRequest = null; + return result; + + } catch (error) { + console.error('[ReportingService] Error fetching campaign reports:', error); + this._pendingReportRequest = null; + throw error; + } + } + + async getCampaignDetails(campaignIds = []) { + if (!Array.isArray(campaignIds) || campaignIds.length === 0) { + return []; + } + + const fetchWithTimeout = async (campaignId, retries = 3) => { + for (let i = 0; i < retries; i++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch( + `${this.baseUrl}/campaigns/${campaignId}?include=campaign-messages`, + { + headers: { + 'Accept': 'application/json', + 'Authorization': `Klaviyo-API-Key ${this.apiKey}`, + 'revision': this.apiRevision + }, + signal: controller.signal + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Failed to fetch campaign ${campaignId}: ${response.status}`); + } + + const data = await response.json(); + if (!data.data) { + throw new Error(`Invalid response for campaign ${campaignId}`); + } + + const message = data.included?.find(item => item.type === 'campaign-message'); + + console.log('[ReportingService] Campaign details for ID:', campaignId, { + send_channel: data.data.attributes.send_channel, + raw_attributes: data.data.attributes + }); + + return { + id: data.data.id, + type: data.data.type, + attributes: { + ...data.data.attributes, + name: data.data.attributes.name, + send_time: data.data.attributes.send_time, + subject: message?.attributes?.content?.subject, + send_channel: data.data.attributes.send_channel || 'email' + } + }; + } catch (error) { + if (i === retries - 1) throw error; + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); // Exponential backoff + } + } + }; + + // Process in smaller chunks to avoid overwhelming the API + const chunkSize = 10; + const campaignDetails = []; + + for (let i = 0; i < campaignIds.length; i += chunkSize) { + const chunk = campaignIds.slice(i, i + chunkSize); + const results = await Promise.all( + chunk.map(id => fetchWithTimeout(id).catch(error => { + console.error(`Failed to fetch campaign ${id}:`, error); + return null; + })) + ); + campaignDetails.push(...results.filter(Boolean)); + + if (i + chunkSize < campaignIds.length) { + await new Promise(resolve => setTimeout(resolve, 1000)); // 1 second delay between chunks + } + } + + return campaignDetails; + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/klaviyo-server/utils/time.utils.js b/inventory-server/dashboard/klaviyo-server/utils/time.utils.js new file mode 100644 index 0000000..ab86c02 --- /dev/null +++ b/inventory-server/dashboard/klaviyo-server/utils/time.utils.js @@ -0,0 +1,448 @@ +import { DateTime } from 'luxon'; + +export class TimeManager { + constructor(dayStartHour = 1) { + this.timezone = 'America/New_York'; + this.dayStartHour = dayStartHour; // Hour (0-23) when the business day starts + this.weekStartDay = 7; // 7 = Sunday in Luxon + } + + /** + * Get the start of the current business day + * If current time is before dayStartHour, return previous day at dayStartHour + */ + getDayStart(dt = this.getNow()) { + if (!dt.isValid) { + console.error("[TimeManager] Invalid datetime provided to getDayStart"); + return this.getNow(); + } + const dayStart = dt.set({ hour: this.dayStartHour, minute: 0, second: 0, millisecond: 0 }); + return dt.hour < this.dayStartHour ? dayStart.minus({ days: 1 }) : dayStart; + } + + /** + * Get the end of the current business day + * End is defined as dayStartHour - 1 minute on the next day + */ + getDayEnd(dt = this.getNow()) { + if (!dt.isValid) { + console.error("[TimeManager] Invalid datetime provided to getDayEnd"); + return this.getNow(); + } + const nextDay = this.getDayStart(dt).plus({ days: 1 }); + return nextDay.minus({ minutes: 1 }); + } + + /** + * Get the start of the week containing the given date + * Aligns with custom day start time and starts on Sunday + */ + getWeekStart(dt = this.getNow()) { + if (!dt.isValid) { + console.error("[TimeManager] Invalid datetime provided to getWeekStart"); + return this.getNow(); + } + // Set to start of week (Sunday) and adjust hour + const weekStart = dt.set({ weekday: this.weekStartDay }).startOf('day'); + // If the week start time would be after the given time, go back a week + if (weekStart > dt) { + return weekStart.minus({ weeks: 1 }).set({ hour: this.dayStartHour }); + } + return weekStart.set({ hour: this.dayStartHour }); + } + + /** + * Convert any date input to a Luxon DateTime in Eastern time + */ + toDateTime(date) { + if (!date) return null; + + if (date instanceof DateTime) { + return date.setZone(this.timezone); + } + + // If it's an ISO string or Date object, parse it + const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date); + if (!dt.isValid) { + console.error("[TimeManager] Invalid date input:", date); + return null; + } + + return dt.setZone(this.timezone); + } + + /** + * Format a date for API requests (UTC ISO string) + */ + formatForAPI(date) { + if (!date) return null; + + // Parse the input date + const dt = this.toDateTime(date); + if (!dt || !dt.isValid) { + console.error("[TimeManager] Invalid date for API:", date); + return null; + } + + // Convert to UTC for API request + const utc = dt.toUTC(); + + console.log("[TimeManager] API date conversion:", { + input: date, + eastern: dt.toISO(), + utc: utc.toISO(), + offset: dt.offset + }); + + return utc.toISO(); + } + + /** + * Format a date for display (in Eastern time) + */ + formatForDisplay(date) { + const dt = this.toDateTime(date); + if (!dt || !dt.isValid) return ''; + return dt.toFormat('LLL d, yyyy h:mm a'); + } + + /** + * Validate if a date range is valid + */ + isValidDateRange(start, end) { + const startDt = this.toDateTime(start); + const endDt = this.toDateTime(end); + return startDt && endDt && endDt > startDt; + } + + /** + * Get the current time in Eastern timezone + */ + getNow() { + return DateTime.now().setZone(this.timezone); + } + + /** + * Get a date range for the last N hours + */ + getLastNHours(hours) { + const now = this.getNow(); + return { + start: now.minus({ hours }), + end: now + }; + } + + /** + * Get a date range for the last N days + * Aligns with custom day start time + */ + getLastNDays(days) { + const now = this.getNow(); + const dayStart = this.getDayStart(now); + return { + start: dayStart.minus({ days }), + end: this.getDayEnd(now) + }; + } + + /** + * Get a date range for a specific time period + * All ranges align with custom day start time + */ + getDateRange(period) { + const now = this.getNow(); + + // Normalize period to handle both 'last' and 'previous' prefixes + const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period; + + switch (normalizedPeriod) { + case 'custom': { + // Custom ranges are handled separately via getCustomRange + console.warn('[TimeManager] Custom ranges should use getCustomRange method'); + return null; + } + case 'today': { + const dayStart = this.getDayStart(now); + return { + start: dayStart, + end: this.getDayEnd(now) + }; + } + case 'yesterday': { + const yesterday = now.minus({ days: 1 }); + return { + start: this.getDayStart(yesterday), + end: this.getDayEnd(yesterday) + }; + } + case 'last7days': { + // For last 7 days, we want to include today and the previous 6 days + const dayStart = this.getDayStart(now); + const weekStart = dayStart.minus({ days: 6 }); + return { + start: weekStart, + end: this.getDayEnd(now) + }; + } + case 'last30days': { + // Include today and previous 29 days + const dayStart = this.getDayStart(now); + const monthStart = dayStart.minus({ days: 29 }); + return { + start: monthStart, + end: this.getDayEnd(now) + }; + } + case 'last90days': { + // Include today and previous 89 days + const dayStart = this.getDayStart(now); + const start = dayStart.minus({ days: 89 }); + return { + start, + end: this.getDayEnd(now) + }; + } + case 'thisWeek': { + // Get the start of the week (Sunday) with custom hour + const weekStart = this.getWeekStart(now); + return { + start: weekStart, + end: this.getDayEnd(now) + }; + } + case 'lastWeek': { + const lastWeek = now.minus({ weeks: 1 }); + const weekStart = this.getWeekStart(lastWeek); + const weekEnd = weekStart.plus({ days: 6 }); // 6 days after start = Saturday + return { + start: weekStart, + end: this.getDayEnd(weekEnd) + }; + } + case 'thisMonth': { + const dayStart = this.getDayStart(now); + const monthStart = dayStart.startOf('month').set({ hour: this.dayStartHour }); + return { + start: monthStart, + end: this.getDayEnd(now) + }; + } + case 'lastMonth': { + const lastMonth = now.minus({ months: 1 }); + const monthStart = lastMonth.startOf('month').set({ hour: this.dayStartHour }); + const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 }); + return { + start: monthStart, + end: this.getDayEnd(monthEnd) + }; + } + default: + console.warn(`[TimeManager] Unknown period: ${period}`); + return null; + } + } + + /** + * Format a duration in milliseconds to a human-readable string + */ + formatDuration(ms) { + return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'"); + } + + /** + * Get relative time string (e.g., "2 hours ago") + */ + getRelativeTime(date) { + const dt = this.toDateTime(date); + if (!dt) return ''; + return dt.toRelative(); + } + + /** + * Get a custom date range using exact dates and times provided + * @param {string} startDate - ISO string or Date for range start + * @param {string} endDate - ISO string or Date for range end + * @returns {Object} Object with start and end DateTime objects + */ + getCustomRange(startDate, endDate) { + if (!startDate || !endDate) { + console.error("[TimeManager] Custom range requires both start and end dates"); + return null; + } + + const start = this.toDateTime(startDate); + const end = this.toDateTime(endDate); + + if (!start || !end || !start.isValid || !end.isValid) { + console.error("[TimeManager] Invalid dates provided for custom range"); + return null; + } + + // Validate the range + if (end < start) { + console.error("[TimeManager] End date must be after start date"); + return null; + } + + return { + start, + end + }; + } + + /** + * Get the previous period's date range based on the current period + * @param {string} period - The current period + * @param {DateTime} now - The current datetime (optional) + * @returns {Object} Object with start and end DateTime objects + */ + getPreviousPeriod(period, now = this.getNow()) { + const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period; + + switch (normalizedPeriod) { + case 'today': { + const yesterday = now.minus({ days: 1 }); + return { + start: this.getDayStart(yesterday), + end: this.getDayEnd(yesterday) + }; + } + case 'yesterday': { + const twoDaysAgo = now.minus({ days: 2 }); + return { + start: this.getDayStart(twoDaysAgo), + end: this.getDayEnd(twoDaysAgo) + }; + } + case 'last7days': { + const dayStart = this.getDayStart(now); + const currentStart = dayStart.minus({ days: 6 }); + const prevEnd = currentStart.minus({ milliseconds: 1 }); + const prevStart = prevEnd.minus({ days: 6 }); + return { + start: prevStart, + end: prevEnd + }; + } + case 'last30days': { + const dayStart = this.getDayStart(now); + const currentStart = dayStart.minus({ days: 29 }); + const prevEnd = currentStart.minus({ milliseconds: 1 }); + const prevStart = prevEnd.minus({ days: 29 }); + return { + start: prevStart, + end: prevEnd + }; + } + case 'last90days': { + const dayStart = this.getDayStart(now); + const currentStart = dayStart.minus({ days: 89 }); + const prevEnd = currentStart.minus({ milliseconds: 1 }); + const prevStart = prevEnd.minus({ days: 89 }); + return { + start: prevStart, + end: prevEnd + }; + } + case 'thisWeek': { + const weekStart = this.getWeekStart(now); + const prevEnd = weekStart.minus({ milliseconds: 1 }); + const prevStart = this.getWeekStart(prevEnd); + return { + start: prevStart, + end: prevEnd + }; + } + case 'lastWeek': { + const lastWeekStart = this.getWeekStart(now.minus({ weeks: 1 })); + const prevEnd = lastWeekStart.minus({ milliseconds: 1 }); + const prevStart = this.getWeekStart(prevEnd); + return { + start: prevStart, + end: prevEnd + }; + } + case 'thisMonth': { + const monthStart = now.startOf('month').set({ hour: this.dayStartHour }); + const prevEnd = monthStart.minus({ milliseconds: 1 }); + const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour }); + return { + start: prevStart, + end: prevEnd + }; + } + case 'lastMonth': { + const lastMonthStart = now.minus({ months: 1 }).startOf('month').set({ hour: this.dayStartHour }); + const prevEnd = lastMonthStart.minus({ milliseconds: 1 }); + const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour }); + return { + start: prevStart, + end: prevEnd + }; + } + default: + console.warn(`[TimeManager] No previous period defined for: ${period}`); + return null; + } + } + + groupEventsByInterval(events, interval = 'day', property = null) { + if (!events?.length) return []; + + const groupedData = new Map(); + const now = DateTime.now().setZone('America/New_York'); + + for (const event of events) { + const datetime = DateTime.fromISO(event.attributes.datetime); + let groupKey; + + switch (interval) { + case 'hour': + groupKey = datetime.startOf('hour').toISO(); + break; + case 'day': + groupKey = datetime.startOf('day').toISO(); + break; + case 'week': + groupKey = datetime.startOf('week').toISO(); + break; + case 'month': + groupKey = datetime.startOf('month').toISO(); + break; + default: + groupKey = datetime.startOf('day').toISO(); + } + + const existingGroup = groupedData.get(groupKey) || { + datetime: groupKey, + count: 0, + value: 0 + }; + + existingGroup.count++; + + if (property) { + // Extract property value from event + const props = event.attributes?.event_properties || event.attributes?.properties || {}; + let value = 0; + + if (property === '$value') { + // Special case for $value - use event value + value = Number(event.attributes?.value || 0); + } else { + // Otherwise get from properties + value = Number(props[property] || 0); + } + + existingGroup.value = (existingGroup.value || 0) + value; + } + + groupedData.set(groupKey, existingGroup); + } + + // Convert to array and sort by datetime + return Array.from(groupedData.values()) + .sort((a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime)); + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/meta-server/package-lock.json b/inventory-server/dashboard/meta-server/package-lock.json new file mode 100644 index 0000000..1080d9d --- /dev/null +++ b/inventory-server/dashboard/meta-server/package-lock.json @@ -0,0 +1,967 @@ +{ + "name": "meta-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meta-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.7.9", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "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.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "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": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "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.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "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/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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" + } + } + } +} diff --git a/inventory-server/dashboard/meta-server/package.json b/inventory-server/dashboard/meta-server/package.json new file mode 100644 index 0000000..d1a17d8 --- /dev/null +++ b/inventory-server/dashboard/meta-server/package.json @@ -0,0 +1,20 @@ +{ + "name": "meta-server", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js", + "dev": "nodemon server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "axios": "^1.7.9", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2" + } +} diff --git a/inventory-server/dashboard/meta-server/routes/campaigns.routes.js b/inventory-server/dashboard/meta-server/routes/campaigns.routes.js new file mode 100644 index 0000000..b4b599d --- /dev/null +++ b/inventory-server/dashboard/meta-server/routes/campaigns.routes.js @@ -0,0 +1,91 @@ +const express = require('express'); +const router = express.Router(); +const { + fetchCampaigns, + fetchAccountInsights, + updateCampaignBudget, + updateCampaignStatus, +} = require('../services/meta.service'); + +// Get all campaigns with insights +router.get('/campaigns', async (req, res) => { + try { + const { since, until } = req.query; + + if (!since || !until) { + return res.status(400).json({ error: 'Date range is required (since, until)' }); + } + + const campaigns = await fetchCampaigns(since, until); + res.json(campaigns); + } catch (error) { + console.error('Campaign fetch error:', error); + res.status(500).json({ + error: 'Failed to fetch campaigns', + details: error.response?.data?.error?.message || error.message, + }); + } +}); + +// Get account insights +router.get('/account-insights', async (req, res) => { + try { + const { since, until } = req.query; + + if (!since || !until) { + return res.status(400).json({ error: 'Date range is required (since, until)' }); + } + + const insights = await fetchAccountInsights(since, until); + res.json(insights); + } catch (error) { + console.error('Account insights fetch error:', error); + res.status(500).json({ + error: 'Failed to fetch account insights', + details: error.response?.data?.error?.message || error.message, + }); + } +}); + +// Update campaign budget +router.patch('/campaigns/:campaignId/budget', async (req, res) => { + try { + const { campaignId } = req.params; + const { budget } = req.body; + + if (!budget) { + return res.status(400).json({ error: 'Budget is required' }); + } + + const result = await updateCampaignBudget(campaignId, budget); + res.json(result); + } catch (error) { + console.error('Budget update error:', error); + res.status(500).json({ + error: 'Failed to update campaign budget', + details: error.response?.data?.error?.message || error.message, + }); + } +}); + +// Update campaign status (pause/unpause) +router.post('/campaigns/:campaignId/:action', async (req, res) => { + try { + const { campaignId, action } = req.params; + + if (!['pause', 'unpause'].includes(action)) { + return res.status(400).json({ error: 'Invalid action. Use "pause" or "unpause"' }); + } + + const result = await updateCampaignStatus(campaignId, action); + res.json(result); + } catch (error) { + console.error('Status update error:', error); + res.status(500).json({ + error: 'Failed to update campaign status', + details: error.response?.data?.error?.message || error.message, + }); + } +}); + +module.exports = router; diff --git a/inventory-server/dashboard/meta-server/server.js b/inventory-server/dashboard/meta-server/server.js new file mode 100644 index 0000000..a2872a5 --- /dev/null +++ b/inventory-server/dashboard/meta-server/server.js @@ -0,0 +1,31 @@ +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +require('dotenv').config({ + path: path.resolve(__dirname, '.env') +}); + +const app = express(); +const port = process.env.PORT || 3005; + +app.use(cors()); +app.use(express.json()); + +// Import routes +const campaignRoutes = require('./routes/campaigns.routes'); + +// Use routes +app.use('/api/meta', campaignRoutes); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Something went wrong!' }); +}); + +// Start server +app.listen(port, () => { + console.log(`Meta API server running on port ${port}`); +}); + +module.exports = app; diff --git a/inventory-server/dashboard/meta-server/services/meta.service.js b/inventory-server/dashboard/meta-server/services/meta.service.js new file mode 100644 index 0000000..b27e1e4 --- /dev/null +++ b/inventory-server/dashboard/meta-server/services/meta.service.js @@ -0,0 +1,99 @@ +const { default: axios } = require('axios'); + +const META_API_VERSION = process.env.META_API_VERSION || 'v21.0'; +const META_API_BASE_URL = `https://graph.facebook.com/${META_API_VERSION}`; +const META_ACCESS_TOKEN = process.env.META_ACCESS_TOKEN; +const AD_ACCOUNT_ID = process.env.META_AD_ACCOUNT_ID; + +const metaApiRequest = async (endpoint, params = {}) => { + try { + const response = await axios.get(`${META_API_BASE_URL}/${endpoint}`, { + params: { + access_token: META_ACCESS_TOKEN, + time_zone: 'America/New_York', + ...params, + }, + }); + return response.data; + } catch (error) { + console.error('Meta API Error:', { + message: error.message, + response: error.response?.data, + endpoint, + }); + throw error; + } +}; + +const fetchCampaigns = async (since, until) => { + const campaigns = await metaApiRequest(`act_${AD_ACCOUNT_ID}/campaigns`, { + fields: [ + 'id', + 'name', + 'status', + 'objective', + 'daily_budget', + 'lifetime_budget', + 'adsets{daily_budget,lifetime_budget}', + `insights.time_range({'since':'${since}','until':'${until}'}).level(campaign){ + spend, + impressions, + clicks, + ctr, + reach, + frequency, + cpm, + cpc, + actions, + action_values, + cost_per_action_type + }`, + ].join(','), + limit: 100, + }); + + return campaigns.data.filter(c => c.insights?.data?.[0]?.spend > 0); +}; + +const fetchAccountInsights = async (since, until) => { + const accountInsights = await metaApiRequest(`act_${AD_ACCOUNT_ID}/insights`, { + fields: 'reach,spend,impressions,clicks,ctr,cpm,actions,action_values', + time_range: JSON.stringify({ since, until }), + }); + + return accountInsights.data[0] || null; +}; + +const updateCampaignBudget = async (campaignId, budget) => { + try { + const response = await axios.post(`${META_API_BASE_URL}/${campaignId}`, { + access_token: META_ACCESS_TOKEN, + daily_budget: budget * 100, // Convert to cents + }); + return response.data; + } catch (error) { + console.error('Update campaign budget error:', error); + throw error; + } +}; + +const updateCampaignStatus = async (campaignId, action) => { + try { + const status = action === 'pause' ? 'PAUSED' : 'ACTIVE'; + const response = await axios.post(`${META_API_BASE_URL}/${campaignId}`, { + access_token: META_ACCESS_TOKEN, + status, + }); + return response.data; + } catch (error) { + console.error('Update campaign status error:', error); + throw error; + } +}; + +module.exports = { + fetchCampaigns, + fetchAccountInsights, + updateCampaignBudget, + updateCampaignStatus, +}; diff --git a/inventory-server/dashboard/meta-server/utils/meta.utils.js b/inventory-server/dashboard/meta-server/utils/meta.utils.js new file mode 100644 index 0000000..e69de29 diff --git a/inventory-server/dashboard/package-lock.json b/inventory-server/dashboard/package-lock.json new file mode 100644 index 0000000..c3891c5 --- /dev/null +++ b/inventory-server/dashboard/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "dotenv": "^16.4.7" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + } + } +} diff --git a/inventory-server/dashboard/package.json b/inventory-server/dashboard/package.json new file mode 100644 index 0000000..33cc0ac --- /dev/null +++ b/inventory-server/dashboard/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "dotenv": "^16.4.7" + } +} diff --git a/inventory-server/dashboard/typeform-server/.env.example b/inventory-server/dashboard/typeform-server/.env.example new file mode 100644 index 0000000..b02ae3e --- /dev/null +++ b/inventory-server/dashboard/typeform-server/.env.example @@ -0,0 +1,13 @@ +# Server Configuration +NODE_ENV=development +TYPEFORM_PORT=3008 + +# Redis Configuration +REDIS_URL=redis://localhost:6379 + +# Typeform API Configuration +TYPEFORM_ACCESS_TOKEN=your_typeform_access_token_here + +# Optional: Form IDs (if you want to store them in env) +TYPEFORM_FORM_ID_1=your_first_form_id +TYPEFORM_FORM_ID_2=your_second_form_id \ No newline at end of file diff --git a/inventory-server/dashboard/typeform-server/package-lock.json b/inventory-server/dashboard/typeform-server/package-lock.json new file mode 100644 index 0000000..e0b28bd --- /dev/null +++ b/inventory-server/dashboard/typeform-server/package-lock.json @@ -0,0 +1,1443 @@ +{ + "name": "typeform-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "typeform-server", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "redis": "^4.6.11" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "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.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "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": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "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.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/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": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/inventory-server/dashboard/typeform-server/package.json b/inventory-server/dashboard/typeform-server/package.json new file mode 100644 index 0000000..47b0fd3 --- /dev/null +++ b/inventory-server/dashboard/typeform-server/package.json @@ -0,0 +1,20 @@ +{ + "name": "typeform-server", + "version": "1.0.0", + "description": "Typeform API integration server", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "redis": "^4.6.11" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} \ No newline at end of file diff --git a/inventory-server/dashboard/typeform-server/routes/typeform.routes.js b/inventory-server/dashboard/typeform-server/routes/typeform.routes.js new file mode 100644 index 0000000..b712989 --- /dev/null +++ b/inventory-server/dashboard/typeform-server/routes/typeform.routes.js @@ -0,0 +1,121 @@ +const express = require('express'); +const router = express.Router(); +const typeformService = require('../services/typeform.service'); + +// Get form responses +router.get('/forms/:formId/responses', async (req, res) => { + try { + const { formId } = req.params; + const filters = req.query; + + console.log(`Fetching responses for form ${formId} with filters:`, filters); + + if (!formId) { + return res.status(400).json({ + error: 'Missing form ID', + details: 'The form ID parameter is required' + }); + } + + const data = await typeformService.getFormResponsesWithFilters(formId, filters); + + if (!data) { + return res.status(404).json({ + error: 'No data found', + details: `No responses found for form ${formId}` + }); + } + + res.json(data); + } catch (error) { + console.error('Form responses error:', { + formId: req.params.formId, + filters: req.query, + error: error.message, + stack: error.stack, + response: error.response?.data + }); + + // Handle specific error cases + if (error.response?.status === 401) { + return res.status(401).json({ + error: 'Authentication failed', + details: 'Invalid Typeform API credentials' + }); + } + + if (error.response?.status === 404) { + return res.status(404).json({ + error: 'Not found', + details: `Form '${req.params.formId}' not found` + }); + } + + if (error.response?.status === 400) { + return res.status(400).json({ + error: 'Invalid request', + details: error.response?.data?.message || 'The request was invalid', + data: error.response?.data + }); + } + + res.status(500).json({ + error: 'Failed to fetch form responses', + details: error.response?.data?.message || error.message, + data: error.response?.data + }); + } +}); + +// Get form insights +router.get('/forms/:formId/insights', async (req, res) => { + try { + const { formId } = req.params; + + if (!formId) { + return res.status(400).json({ + error: 'Missing form ID', + details: 'The form ID parameter is required' + }); + } + + const data = await typeformService.getFormInsights(formId); + + if (!data) { + return res.status(404).json({ + error: 'No data found', + details: `No insights found for form ${formId}` + }); + } + + res.json(data); + } catch (error) { + console.error('Form insights error:', { + formId: req.params.formId, + error: error.message, + response: error.response?.data + }); + + if (error.response?.status === 401) { + return res.status(401).json({ + error: 'Authentication failed', + details: 'Invalid Typeform API credentials' + }); + } + + if (error.response?.status === 404) { + return res.status(404).json({ + error: 'Not found', + details: `Form '${req.params.formId}' not found` + }); + } + + res.status(500).json({ + error: 'Failed to fetch form insights', + details: error.response?.data?.message || error.message, + data: error.response?.data + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/dashboard/typeform-server/server.js b/inventory-server/dashboard/typeform-server/server.js new file mode 100644 index 0000000..44a51dd --- /dev/null +++ b/inventory-server/dashboard/typeform-server/server.js @@ -0,0 +1,31 @@ +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +require('dotenv').config({ + path: path.resolve(__dirname, '.env') +}); + +const app = express(); +const port = process.env.TYPEFORM_PORT || 3008; + +app.use(cors()); +app.use(express.json()); + +// Import routes +const typeformRoutes = require('./routes/typeform.routes'); + +// Use routes +app.use('/api/typeform', typeformRoutes); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Something went wrong!' }); +}); + +// Start server +app.listen(port, () => { + console.log(`Typeform API server running on port ${port}`); +}); + +module.exports = app; \ No newline at end of file diff --git a/inventory-server/dashboard/typeform-server/services/typeform.service.js b/inventory-server/dashboard/typeform-server/services/typeform.service.js new file mode 100644 index 0000000..e6fc67e --- /dev/null +++ b/inventory-server/dashboard/typeform-server/services/typeform.service.js @@ -0,0 +1,142 @@ +const axios = require('axios'); +const { createClient } = require('redis'); + +class TypeformService { + constructor() { + this.redis = createClient({ + url: process.env.REDIS_URL + }); + + this.redis.on('error', err => console.error('Redis Client Error:', err)); + this.redis.connect().catch(err => console.error('Redis connection error:', err)); + + const token = process.env.TYPEFORM_ACCESS_TOKEN; + console.log('Initializing Typeform client with token:', token ? `${token.slice(0, 10)}...` : 'missing'); + + this.apiClient = axios.create({ + baseURL: 'https://api.typeform.com', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + // Test the token + this.testConnection(); + } + + async testConnection() { + try { + const response = await this.apiClient.get('/forms'); + console.log('Typeform connection test successful:', { + status: response.status, + headers: response.headers, + }); + } catch (error) { + console.error('Typeform connection test failed:', { + error: error.message, + response: error.response?.data, + status: error.response?.status, + }); + } + } + + async getFormResponses(formId, params = {}) { + const cacheKey = `typeform:responses:${formId}:${JSON.stringify(params)}`; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + console.log(`Form responses for ${formId} found in Redis cache`); + return JSON.parse(cachedData); + } + + // Fetch from API + const response = await this.apiClient.get(`/forms/${formId}/responses`, { params }); + const data = response.data; + + // Save to Redis with 5 minute expiry + await this.redis.set(cacheKey, JSON.stringify(data), { + EX: 300 // 5 minutes + }); + + return data; + } catch (error) { + console.error(`Error fetching form responses for ${formId}:`, { + error: error.message, + params, + response: error.response?.data + }); + throw error; + } + } + + async getFormInsights(formId) { + const cacheKey = `typeform:insights:${formId}`; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + console.log(`Form insights for ${formId} found in Redis cache`); + return JSON.parse(cachedData); + } + + // Log the request details + console.log(`Fetching insights for form ${formId}...`, { + url: `/insights/${formId}/summary`, + headers: this.apiClient.defaults.headers + }); + + // Fetch from API + const response = await this.apiClient.get(`/insights/${formId}/summary`); + console.log('Typeform insights response:', { + status: response.status, + headers: response.headers, + data: response.data + }); + const data = response.data; + + // Save to Redis with 5 minute expiry + await this.redis.set(cacheKey, JSON.stringify(data), { + EX: 300 // 5 minutes + }); + + return data; + } catch (error) { + console.error(`Error fetching form insights for ${formId}:`, { + error: error.message, + response: error.response?.data, + status: error.response?.status, + headers: error.response?.headers, + requestUrl: `/insights/${formId}/summary`, + requestHeaders: this.apiClient.defaults.headers + }); + throw error; + } + } + + async getFormResponsesWithFilters(formId, { since, until, pageSize = 25, ...otherParams } = {}) { + try { + const params = { + page_size: pageSize, + ...otherParams + }; + + if (since) { + params.since = new Date(since).toISOString(); + } + if (until) { + params.until = new Date(until).toISOString(); + } + + return await this.getFormResponses(formId, params); + } catch (error) { + console.error('Error in getFormResponsesWithFilters:', error); + throw error; + } + } +} + +module.exports = new TypeformService(); \ No newline at end of file diff --git a/inventory-server/db/config-schema-new.sql b/inventory-server/db/config-schema-new.sql new file mode 100644 index 0000000..926155f --- /dev/null +++ b/inventory-server/db/config-schema-new.sql @@ -0,0 +1,196 @@ +-- Create function for updating timestamps if it doesn't exist +CREATE OR REPLACE FUNCTION update_updated_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create function for updating updated_at timestamps +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Drop tables in reverse order of dependency +DROP TABLE IF EXISTS public.settings_product CASCADE; +DROP TABLE IF EXISTS public.settings_vendor CASCADE; +DROP TABLE IF EXISTS public.settings_global CASCADE; + +-- Table Definition: settings_global +CREATE TABLE public.settings_global ( + setting_key VARCHAR PRIMARY KEY, + setting_value VARCHAR NOT NULL, + description TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Table Definition: settings_vendor +CREATE TABLE public.settings_vendor ( + vendor VARCHAR PRIMARY KEY, -- Matches products.vendor + default_lead_time_days INT, + default_days_of_stock INT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- Index for faster lookups if needed (PK usually sufficient) +-- CREATE INDEX idx_settings_vendor_vendor ON public.settings_vendor(vendor); + +-- Table Definition: settings_product +CREATE TABLE public.settings_product ( + pid INT8 PRIMARY KEY, + lead_time_days INT, -- Overrides vendor/global + days_of_stock INT, -- Overrides vendor/global + safety_stock INT DEFAULT 0, -- Minimum desired stock level + forecast_method VARCHAR DEFAULT 'standard', -- e.g., 'standard', 'seasonal' + exclude_from_forecast BOOLEAN DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_settings_product_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE +); + + +-- Description: Inserts or updates standard default global settings. +-- Safe to rerun; will update existing keys with these default values. +-- Dependencies: `settings_global` table must exist. +-- Frequency: Run once initially, or rerun if you want to reset global defaults. + +INSERT INTO public.settings_global (setting_key, setting_value, description) VALUES + ('abc_revenue_threshold_a', '0.80', 'Revenue percentage for Class A (cumulative)'), + ('abc_revenue_threshold_b', '0.95', 'Revenue percentage for Class B (cumulative)'), + ('abc_calculation_basis', 'revenue_30d', 'Metric for ABC calc (revenue_30d, sales_30d, lifetime_revenue)'), + ('abc_calculation_period', '30', 'Days period for ABC calculation if not lifetime'), + ('default_forecast_method', 'standard', 'Default forecast method (standard, seasonal)'), + ('default_lead_time_days', '14', 'Global default lead time in days'), + ('default_days_of_stock', '30', 'Global default days of stock coverage target'), + -- Set default safety stock to 0 units. Can be overridden per product. + -- If you wanted safety stock in days, you'd store 'days' here and calculate units later. + ('default_safety_stock_units', '0', 'Global default safety stock in units') +ON CONFLICT (setting_key) DO UPDATE SET + setting_value = EXCLUDED.setting_value, + description = EXCLUDED.description, + updated_at = CURRENT_TIMESTAMP; -- Update timestamp if default value changes + + + +-- Description: Creates placeholder rows in `settings_vendor` for each unique vendor +-- found in the `products` table. Does NOT set specific overrides. +-- Safe to rerun; will NOT overwrite existing vendor settings. +-- Dependencies: `settings_vendor` table must exist, `products` table populated. +-- Frequency: Run once after initial product load, or periodically if new vendors are added. + +INSERT INTO public.settings_vendor ( + vendor, + default_lead_time_days, + default_days_of_stock + -- updated_at will use its default CURRENT_TIMESTAMP on insert +) +SELECT + DISTINCT p.vendor, + -- Explicitly cast NULL to INTEGER to resolve type mismatch + CAST(NULL AS INTEGER), + CAST(NULL AS INTEGER) +FROM + public.products p +WHERE + p.vendor IS NOT NULL + AND p.vendor <> '' -- Exclude blank vendors if necessary + +ON CONFLICT (vendor) DO NOTHING; -- IMPORTANT: Do not overwrite existing vendor settings + +SELECT COUNT(*) FROM public.settings_vendor; -- Verify rows were inserted + + +-- Description: Creates placeholder rows in `settings_product` for each unique product +-- found in the `products` table. Sets basic defaults but no specific overrides. +-- Safe to rerun; will NOT overwrite existing product settings. +-- Dependencies: `settings_product` table must exist, `products` table populated. +-- Frequency: Run once after initial product load, or periodically if new products are added. + +INSERT INTO public.settings_product ( + pid, + lead_time_days, -- NULL = Inherit from Vendor/Global + days_of_stock, -- NULL = Inherit from Vendor/Global + safety_stock, -- Default to 0 units initially + forecast_method, -- NULL = Inherit from Global ('standard') + exclude_from_forecast -- Default to FALSE + -- updated_at will use its default CURRENT_TIMESTAMP on insert +) +SELECT + p.pid, + CAST(NULL AS INTEGER), -- Explicitly cast NULL to INTEGER + CAST(NULL AS INTEGER), -- Explicitly cast NULL to INTEGER + COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0), -- Use global default safety stock units + CAST(NULL AS VARCHAR), -- Cast NULL to VARCHAR for forecast_method (already varchar, but explicit) + FALSE -- Default: Include in forecast +FROM + public.products p + +ON CONFLICT (pid) DO NOTHING; -- IMPORTANT: Do not overwrite existing product-specific settings + + +-- History and status tables +CREATE TABLE IF NOT EXISTS calculate_history ( + id BIGSERIAL PRIMARY KEY, + start_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP WITH TIME ZONE NULL, + duration_seconds INTEGER, + duration_minutes DECIMAL(10,2) GENERATED ALWAYS AS (duration_seconds::decimal / 60.0) STORED, + total_products INTEGER DEFAULT 0, + total_orders INTEGER DEFAULT 0, + total_purchase_orders INTEGER DEFAULT 0, + processed_products INTEGER DEFAULT 0, + processed_orders INTEGER DEFAULT 0, + processed_purchase_orders INTEGER DEFAULT 0, + status calculation_status DEFAULT 'running', + error_message TEXT, + additional_info JSONB +); + +CREATE TABLE IF NOT EXISTS calculate_status ( + module_name text PRIMARY KEY, + last_calculation_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sync_status ( + table_name TEXT PRIMARY KEY, + last_sync_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_sync_id BIGINT +); + +CREATE TABLE IF NOT EXISTS import_history ( + id BIGSERIAL PRIMARY KEY, + table_name VARCHAR(50) NOT NULL, + start_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP WITH TIME ZONE NULL, + duration_seconds INTEGER, + duration_minutes DECIMAL(10,2) GENERATED ALWAYS AS (duration_seconds::decimal / 60.0) STORED, + records_added INTEGER DEFAULT 0, + records_updated INTEGER DEFAULT 0, + records_deleted INTEGER DEFAULT 0, + records_skipped INTEGER DEFAULT 0, + total_processed INTEGER DEFAULT 0, + is_incremental BOOLEAN DEFAULT FALSE, + status calculation_status DEFAULT 'running', + error_message TEXT, + additional_info JSONB +); + +-- Create all indexes after tables are fully created +CREATE INDEX IF NOT EXISTS idx_last_calc ON calculate_status(last_calculation_timestamp); +CREATE INDEX IF NOT EXISTS idx_last_sync ON sync_status(last_sync_timestamp); +CREATE INDEX IF NOT EXISTS idx_table_time ON import_history(table_name, start_time); +CREATE INDEX IF NOT EXISTS idx_import_history_status ON import_history(status); +CREATE INDEX IF NOT EXISTS idx_calculate_history_status ON calculate_history(status); + +-- Add comments for documentation +COMMENT ON TABLE import_history IS 'Tracks history of data import operations with detailed statistics'; +COMMENT ON COLUMN import_history.records_deleted IS 'Number of records deleted during this import'; +COMMENT ON COLUMN import_history.records_skipped IS 'Number of records skipped (e.g., unchanged, invalid)'; +COMMENT ON COLUMN import_history.total_processed IS 'Total number of records examined/processed, including skipped'; + +COMMENT ON TABLE calculate_history IS 'Tracks history of metrics calculation runs with performance data'; +COMMENT ON COLUMN calculate_history.duration_seconds IS 'Total duration of the calculation in seconds'; +COMMENT ON COLUMN calculate_history.additional_info IS 'JSON object containing step timings, row counts, and other detailed metrics'; \ No newline at end of file diff --git a/inventory-server/db/metrics-schema-new.sql b/inventory-server/db/metrics-schema-new.sql new file mode 100644 index 0000000..15dfe61 --- /dev/null +++ b/inventory-server/db/metrics-schema-new.sql @@ -0,0 +1,344 @@ +-- Drop tables in reverse order of dependency +DROP TABLE IF EXISTS public.product_metrics CASCADE; +DROP TABLE IF EXISTS public.daily_product_snapshots CASCADE; + +-- Table Definition: daily_product_snapshots +CREATE TABLE public.daily_product_snapshots ( + snapshot_date DATE NOT NULL, + pid INT8 NOT NULL, + sku VARCHAR, -- Copied for convenience + + -- Inventory Metrics (End of Day / Last Snapshot of Day) + eod_stock_quantity INT NOT NULL DEFAULT 0, + eod_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00, -- Increased precision + eod_stock_retail NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + eod_stock_gross NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + stockout_flag BOOLEAN NOT NULL DEFAULT FALSE, + + -- Sales Metrics (Aggregated for the snapshot_date) + units_sold INT NOT NULL DEFAULT 0, + units_returned INT NOT NULL DEFAULT 0, + gross_revenue NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + discounts NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + returns_revenue NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + net_revenue NUMERIC(14, 4) NOT NULL DEFAULT 0.00, -- gross_revenue - discounts + cogs NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + gross_regular_revenue NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + profit NUMERIC(14, 4) NOT NULL DEFAULT 0.00, -- net_revenue - cogs + + -- Receiving Metrics (Aggregated for the snapshot_date) + units_received INT NOT NULL DEFAULT 0, + cost_received NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + + calculation_timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (snapshot_date, pid) -- Composite primary key + -- CONSTRAINT fk_daily_snapshot_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE -- FK Optional on snapshot table +); + +-- Add Indexes for daily_product_snapshots +CREATE INDEX idx_daily_snapshot_pid_date ON public.daily_product_snapshots(pid, snapshot_date); -- Useful for product-specific time series + + +-- Table Definition: product_metrics +CREATE TABLE public.product_metrics ( + pid INT8 PRIMARY KEY, + last_calculated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Product Info (Copied for convenience/performance) + sku VARCHAR, + title VARCHAR, + brand VARCHAR, + vendor VARCHAR, + image_url VARCHAR, -- (e.g., products.image_175) + is_visible BOOLEAN, + is_replenishable BOOLEAN, + + -- Additional product fields + barcode VARCHAR, + harmonized_tariff_code VARCHAR, + vendor_reference VARCHAR, + notions_reference VARCHAR, + line VARCHAR, + subline VARCHAR, + artist VARCHAR, + moq INT, + rating NUMERIC(10, 2), + reviews INT, + weight NUMERIC(14, 4), + length NUMERIC(14, 4), + width NUMERIC(14, 4), + height NUMERIC(14, 4), + country_of_origin VARCHAR, + location VARCHAR, + baskets INT, + notifies INT, + preorder_count INT, + notions_inv_count INT, + + -- Current Status (Refreshed Hourly) + current_price NUMERIC(10, 2), + current_regular_price NUMERIC(10, 2), + current_cost_price NUMERIC(10, 4), -- Increased precision for cost + current_landing_cost_price NUMERIC(10, 4), -- Increased precision for cost + current_stock INT NOT NULL DEFAULT 0, + current_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + current_stock_retail NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + current_stock_gross NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + on_order_qty INT NOT NULL DEFAULT 0, + on_order_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + on_order_retail NUMERIC(14, 4) NOT NULL DEFAULT 0.00, + earliest_expected_date DATE, + -- total_received_lifetime INT NOT NULL DEFAULT 0, -- Can calc if needed + + -- Historical Dates (Calculated Once/Periodically) + date_created DATE, + date_first_received DATE, + date_last_received DATE, + date_first_sold DATE, + date_last_sold DATE, + age_days INT, -- Calculated based on LEAST(date_created, date_first_sold) + + -- Rolling Period Metrics (Refreshed Hourly from daily_product_snapshots) + sales_7d INT, revenue_7d NUMERIC(14, 4), + sales_14d INT, revenue_14d NUMERIC(14, 4), + sales_30d INT, revenue_30d NUMERIC(14, 4), + cogs_30d NUMERIC(14, 4), profit_30d NUMERIC(14, 4), + returns_units_30d INT, returns_revenue_30d NUMERIC(14, 4), + discounts_30d NUMERIC(14, 4), + gross_revenue_30d NUMERIC(14, 4), gross_regular_revenue_30d NUMERIC(14, 4), + stockout_days_30d INT, + sales_365d INT, revenue_365d NUMERIC(14, 4), + avg_stock_units_30d NUMERIC(10, 2), avg_stock_cost_30d NUMERIC(14, 4), + avg_stock_retail_30d NUMERIC(14, 4), avg_stock_gross_30d NUMERIC(14, 4), + received_qty_30d INT, received_cost_30d NUMERIC(14, 4), + + -- Lifetime Metrics (Recalculated Hourly/Daily from daily_product_snapshots) + lifetime_sales INT, + lifetime_revenue NUMERIC(16, 4), + lifetime_revenue_quality VARCHAR(10), -- 'exact', 'partial', 'estimated' + + -- First Period Metrics (Calculated Once/Periodically from daily_product_snapshots) + first_7_days_sales INT, first_7_days_revenue NUMERIC(14, 4), + first_30_days_sales INT, first_30_days_revenue NUMERIC(14, 4), + first_60_days_sales INT, first_60_days_revenue NUMERIC(14, 4), + first_90_days_sales INT, first_90_days_revenue NUMERIC(14, 4), + + -- Calculated KPIs (Refreshed Hourly based on rolling metrics) + asp_30d NUMERIC(10, 2), -- revenue_30d / sales_30d + acp_30d NUMERIC(10, 4), -- cogs_30d / sales_30d + avg_ros_30d NUMERIC(10, 4), -- profit_30d / sales_30d + avg_sales_per_day_30d NUMERIC(10, 2), -- sales_30d / 30.0 + avg_sales_per_month_30d NUMERIC(10, 2), -- sales_30d (assuming 30d = 1 month for this metric) + margin_30d NUMERIC(8, 2), -- (profit_30d / revenue_30d) * 100 + markup_30d NUMERIC(8, 2), -- (profit_30d / cogs_30d) * 100 + gmroi_30d NUMERIC(10, 2), -- profit_30d / avg_stock_cost_30d + stockturn_30d NUMERIC(10, 2), -- sales_30d / avg_stock_units_30d + return_rate_30d NUMERIC(8, 2), -- returns_units_30d / (sales_30d + returns_units_30d) * 100 + discount_rate_30d NUMERIC(8, 2), -- discounts_30d / gross_revenue_30d * 100 + stockout_rate_30d NUMERIC(8, 2), -- stockout_days_30d / 30.0 * 100 + markdown_30d NUMERIC(14, 4), -- gross_regular_revenue_30d - gross_revenue_30d + markdown_rate_30d NUMERIC(8, 2), -- markdown_30d / gross_regular_revenue_30d * 100 + sell_through_30d NUMERIC(8, 2), -- sales_30d / (current_stock + sales_30d) * 100 + avg_lead_time_days INT, -- Calculated Periodically from purchase_orders + + -- Forecasting & Replenishment (Refreshed Hourly) + abc_class CHAR(1), -- Updated Periodically (e.g., Weekly) + sales_velocity_daily NUMERIC(10, 4), -- sales_30d / (30.0 - stockout_days_30d) + config_lead_time INT, -- From settings tables + config_days_of_stock INT, -- From settings tables + config_safety_stock INT, -- From settings_product + planning_period_days INT, -- config_lead_time + config_days_of_stock + lead_time_forecast_units NUMERIC(10, 2), -- sales_velocity_daily * config_lead_time + days_of_stock_forecast_units NUMERIC(10, 2), -- sales_velocity_daily * config_days_of_stock + planning_period_forecast_units NUMERIC(10, 2), -- lead_time_forecast_units + days_of_stock_forecast_units + lead_time_closing_stock NUMERIC(10, 2), -- current_stock + on_order_qty - lead_time_forecast_units + days_of_stock_closing_stock NUMERIC(10, 2), -- lead_time_closing_stock - days_of_stock_forecast_units + replenishment_needed_raw NUMERIC(10, 2), -- planning_period_forecast_units + config_safety_stock - current_stock - on_order_qty + replenishment_units INT, -- CEILING(GREATEST(0, replenishment_needed_raw)) + replenishment_cost NUMERIC(14, 4), -- replenishment_units * COALESCE(current_landing_cost_price, current_cost_price) + replenishment_retail NUMERIC(14, 4), -- replenishment_units * current_price + replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - COALESCE(current_landing_cost_price, current_cost_price)) + to_order_units INT, -- Apply MOQ/UOM logic to replenishment_units + forecast_lost_sales_units NUMERIC(10, 2), -- GREATEST(0, -lead_time_closing_stock) + forecast_lost_revenue NUMERIC(14, 4), -- forecast_lost_sales_units * current_price + stock_cover_in_days NUMERIC(10, 1), -- current_stock / sales_velocity_daily + po_cover_in_days NUMERIC(10, 1), -- on_order_qty / sales_velocity_daily + sells_out_in_days NUMERIC(10, 1), -- (current_stock + on_order_qty) / sales_velocity_daily + replenish_date DATE, -- Calc based on when stock hits safety stock minus lead time + overstocked_units INT, -- GREATEST(0, current_stock - config_safety_stock - planning_period_forecast_units) + overstocked_cost NUMERIC(14, 4), -- overstocked_units * COALESCE(current_landing_cost_price, current_cost_price) + overstocked_retail NUMERIC(14, 4), -- overstocked_units * current_price + is_old_stock BOOLEAN, -- Based on age, last sold, last received, on_order status + + -- Yesterday's Metrics (Refreshed Hourly from daily_product_snapshots) + yesterday_sales INT, + + -- Product Status (Calculated from metrics) + status VARCHAR, -- Stores status values like: Critical, Reorder Soon, Healthy, Overstock, At Risk, New + + -- Growth Metrics (P3) + sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth current 30d vs prev 30d + revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth current 30d vs prev 30d + sales_growth_yoy NUMERIC(10, 2), -- Year-over-year sales growth % + revenue_growth_yoy NUMERIC(10, 2), -- Year-over-year revenue growth % + + -- Demand Variability Metrics (P3) + sales_variance_30d NUMERIC(10, 2), -- Variance of daily sales + sales_std_dev_30d NUMERIC(10, 2), -- Standard deviation of daily sales + sales_cv_30d NUMERIC(10, 2), -- Coefficient of variation + demand_pattern VARCHAR(20), -- 'stable', 'variable', 'sporadic', 'lumpy' + + -- Service Level & Fill Rate (P5) + fill_rate_30d NUMERIC(8, 2), -- % of demand fulfilled from stock + stockout_incidents_30d INT, -- Days with stockouts + service_level_30d NUMERIC(8, 2), -- % of days without stockouts + lost_sales_incidents_30d INT, -- Days with potential lost sales + + -- Seasonality (P5) + seasonality_index NUMERIC(10, 2), -- Current vs average (100 = average) + seasonal_pattern VARCHAR(20), -- 'none', 'weekly', 'monthly', 'quarterly', 'yearly' + peak_season VARCHAR(20), -- e.g., 'Q4', 'summer', 'holiday' + + CONSTRAINT fk_product_metrics_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Add Indexes for product_metrics (adjust based on common filtering/sorting in frontend) +CREATE INDEX idx_product_metrics_brand ON public.product_metrics(brand); +CREATE INDEX idx_product_metrics_vendor ON public.product_metrics(vendor); +CREATE INDEX idx_product_metrics_sku ON public.product_metrics(sku); +CREATE INDEX idx_product_metrics_abc_class ON public.product_metrics(abc_class); +CREATE INDEX idx_product_metrics_revenue_30d ON public.product_metrics(revenue_30d DESC NULLS LAST); -- Example sorting index +CREATE INDEX idx_product_metrics_sales_30d ON public.product_metrics(sales_30d DESC NULLS LAST); -- Example sorting index +CREATE INDEX idx_product_metrics_current_stock ON public.product_metrics(current_stock); +CREATE INDEX idx_product_metrics_sells_out_in_days ON public.product_metrics(sells_out_in_days ASC NULLS LAST); -- Example sorting index +CREATE INDEX idx_product_metrics_status ON public.product_metrics(status); -- Index for status filtering + +-- Add new vendor, category, and brand metrics tables +-- Drop tables in reverse order if they exist +DROP TABLE IF EXISTS public.brand_metrics CASCADE; +DROP TABLE IF EXISTS public.vendor_metrics CASCADE; +DROP TABLE IF EXISTS public.category_metrics CASCADE; + +-- ========= Category Metrics ========= +CREATE TABLE public.category_metrics ( + category_id INT8 PRIMARY KEY, -- Foreign key to categories.cat_id + category_name VARCHAR, -- Denormalized for convenience + category_type INT2, -- Denormalized for convenience + parent_id INT8, -- Denormalized for convenience + last_calculated TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- ROLLED-UP METRICS (includes this category + all descendants) + -- Counts & Basic Info + product_count INT NOT NULL DEFAULT 0, -- Total products linked + active_product_count INT NOT NULL DEFAULT 0, -- Visible products linked + replenishable_product_count INT NOT NULL DEFAULT 0,-- Replenishable products linked + + -- Current Stock Value (approximated using current product costs/prices) + current_stock_units INT NOT NULL DEFAULT 0, + current_stock_cost NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + current_stock_retail NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + + -- Rolling Period Aggregates (Summed from product_metrics) + sales_7d INT NOT NULL DEFAULT 0, revenue_7d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + sales_30d INT NOT NULL DEFAULT 0, revenue_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + profit_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, cogs_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + sales_365d INT NOT NULL DEFAULT 0, revenue_365d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00, + + -- DIRECT METRICS (only products directly in this category) + direct_product_count INT NOT NULL DEFAULT 0, -- Products directly in this category + direct_active_product_count INT NOT NULL DEFAULT 0, -- Visible products directly in this category + direct_replenishable_product_count INT NOT NULL DEFAULT 0,-- Replenishable products directly in this category + + -- Direct Current Stock Value + direct_current_stock_units INT NOT NULL DEFAULT 0, + direct_stock_cost NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + direct_stock_retail NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + + -- Direct Rolling Period Aggregates + direct_sales_7d INT NOT NULL DEFAULT 0, direct_revenue_7d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + direct_sales_30d INT NOT NULL DEFAULT 0, direct_revenue_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + direct_profit_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, direct_cogs_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + direct_sales_365d INT NOT NULL DEFAULT 0, direct_revenue_365d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + direct_lifetime_sales INT NOT NULL DEFAULT 0, direct_lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00, + + -- Calculated KPIs (Based on 30d aggregates) - Apply to rolled-up metrics + avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100 + stock_turn_30d NUMERIC(10, 3), -- sales_units / avg_stock_units (Needs avg stock calc) + sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units + revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue + + CONSTRAINT fk_category_metrics_cat_id FOREIGN KEY (category_id) REFERENCES public.categories(cat_id) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX idx_category_metrics_name ON public.category_metrics(category_name); +CREATE INDEX idx_category_metrics_type ON public.category_metrics(category_type); + +-- ========= Vendor Metrics ========= +CREATE TABLE public.vendor_metrics ( + vendor_name VARCHAR PRIMARY KEY, -- Matches products.vendor + last_calculated TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Counts & Basic Info + product_count INT NOT NULL DEFAULT 0, -- Total products from this vendor + active_product_count INT NOT NULL DEFAULT 0, -- Visible products + replenishable_product_count INT NOT NULL DEFAULT 0,-- Replenishable products + + -- Current Stock Value (approximated) + current_stock_units INT NOT NULL DEFAULT 0, + current_stock_cost NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + current_stock_retail NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + + -- On Order Value + on_order_units INT NOT NULL DEFAULT 0, + on_order_cost NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + + -- PO Performance (Simplified) + po_count_365d INT NOT NULL DEFAULT 0, -- Count of distinct POs created in last year + avg_lead_time_days INT, -- Calculated from received POs historically + + -- Rolling Period Aggregates (Summed from product_metrics) + sales_7d INT NOT NULL DEFAULT 0, revenue_7d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + sales_30d INT NOT NULL DEFAULT 0, revenue_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + profit_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, cogs_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + sales_365d INT NOT NULL DEFAULT 0, revenue_365d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00, + + -- Calculated KPIs (Based on 30d aggregates) + avg_margin_30d NUMERIC(14, 4), -- (profit / revenue) * 100 + sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units + revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue + -- Add more KPIs if needed (e.g., avg product value, sell-through rate for vendor) +); +CREATE INDEX idx_vendor_metrics_active_count ON public.vendor_metrics(active_product_count); + + +-- ========= Brand Metrics ========= +CREATE TABLE public.brand_metrics ( + brand_name VARCHAR PRIMARY KEY, -- Matches products.brand (use 'Unbranded' for NULLs) + last_calculated TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Counts & Basic Info + product_count INT NOT NULL DEFAULT 0, -- Total products of this brand + active_product_count INT NOT NULL DEFAULT 0, -- Visible products + replenishable_product_count INT NOT NULL DEFAULT 0,-- Replenishable products + + -- Current Stock Value (approximated) + current_stock_units INT NOT NULL DEFAULT 0, + current_stock_cost NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + current_stock_retail NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + + -- Rolling Period Aggregates (Summed from product_metrics) + sales_7d INT NOT NULL DEFAULT 0, revenue_7d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + sales_30d INT NOT NULL DEFAULT 0, revenue_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + profit_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, cogs_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + sales_365d INT NOT NULL DEFAULT 0, revenue_365d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, + lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00, + + -- Calculated KPIs (Based on 30d aggregates) + avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100 + sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units + revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue + -- Add more KPIs if needed (e.g., avg product value, sell-through rate for brand) +); +CREATE INDEX idx_brand_metrics_active_count ON public.brand_metrics(active_product_count); \ No newline at end of file diff --git a/inventory-server/db/schema.sql b/inventory-server/db/schema.sql new file mode 100644 index 0000000..1b36110 --- /dev/null +++ b/inventory-server/db/schema.sql @@ -0,0 +1,304 @@ +-- Enable strict error reporting +SET session_replication_role = 'replica'; -- Disable foreign key checks temporarily + +-- Create function for updating timestamps +CREATE OR REPLACE FUNCTION update_updated_column() RETURNS TRIGGER AS $func$ +BEGIN + -- Check which table is being updated and use the appropriate column + IF TG_TABLE_NAME = 'categories' THEN + NEW.updated_at = CURRENT_TIMESTAMP; + ELSIF TG_TABLE_NAME IN ('products', 'orders', 'purchase_orders', 'receivings') THEN + NEW.updated = CURRENT_TIMESTAMP; + END IF; + RETURN NEW; +END; +$func$ language plpgsql; + +-- Create tables +CREATE TABLE products ( + pid BIGINT NOT NULL, + title TEXT NOT NULL, + description TEXT, + sku TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE, + first_received TIMESTAMP WITH TIME ZONE, + stock_quantity INTEGER DEFAULT 0, + preorder_count INTEGER DEFAULT 0, + notions_inv_count INTEGER DEFAULT 0, + price NUMERIC(14, 4) NOT NULL, + regular_price NUMERIC(14, 4) NOT NULL, + cost_price NUMERIC(14, 4), + landing_cost_price NUMERIC(14, 4), + barcode TEXT, + harmonized_tariff_code TEXT, + updated_at TIMESTAMP WITH TIME ZONE, + visible BOOLEAN DEFAULT true, + managing_stock BOOLEAN DEFAULT true, + replenishable BOOLEAN DEFAULT true, + vendor TEXT, + vendor_reference TEXT, + notions_reference TEXT, + permalink TEXT, + categories TEXT, + image TEXT, + image_175 TEXT, + image_full TEXT, + brand TEXT, + line TEXT, + subline TEXT, + artist TEXT, + options TEXT, + tags TEXT, + moq INTEGER DEFAULT 1, + uom INTEGER DEFAULT 1, + rating NUMERIC(14, 4) DEFAULT 0.00, + reviews INTEGER DEFAULT 0, + weight NUMERIC(14, 4), + length NUMERIC(14, 4), + width NUMERIC(14, 4), + height NUMERIC(14, 4), + country_of_origin TEXT, + location TEXT, + total_sold INTEGER DEFAULT 0, + baskets INTEGER DEFAULT 0, + notifies INTEGER DEFAULT 0, + date_last_sold DATE, + updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (pid) +); + +-- Create trigger for products +CREATE TRIGGER update_products_updated + BEFORE UPDATE ON products + FOR EACH ROW + EXECUTE FUNCTION update_updated_column(); + +-- Create indexes for products table +CREATE INDEX idx_products_sku ON products(sku); +CREATE INDEX idx_products_vendor ON products(vendor); +CREATE INDEX idx_products_brand ON products(brand); +CREATE INDEX idx_products_visible ON products(visible); +CREATE INDEX idx_products_replenishable ON products(replenishable); +CREATE INDEX idx_products_updated ON products(updated); + +-- Create categories table with hierarchy support +CREATE TABLE categories ( + cat_id BIGINT PRIMARY KEY, + name TEXT NOT NULL, + type SMALLINT NOT NULL, + parent_id BIGINT, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'active', + FOREIGN KEY (parent_id) REFERENCES categories(cat_id) ON DELETE SET NULL +); + +-- Create trigger for categories +CREATE TRIGGER update_categories_updated_at + BEFORE UPDATE ON categories + FOR EACH ROW + EXECUTE FUNCTION update_updated_column(); + +COMMENT ON COLUMN categories.type IS '10=section, 11=category, 12=subcategory, 13=subsubcategory, 1=company, 2=line, 3=subline, 40=artist'; + +CREATE INDEX idx_categories_parent ON categories(parent_id); +CREATE INDEX idx_categories_type ON categories(type); +CREATE INDEX idx_categories_status ON categories(status); +CREATE INDEX idx_categories_name ON categories(name); +CREATE INDEX idx_categories_name_type ON categories(name, type); + +-- Create product_categories junction table +CREATE TABLE product_categories ( + cat_id BIGINT NOT NULL, + pid BIGINT NOT NULL, + PRIMARY KEY (pid, cat_id), + FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE, + FOREIGN KEY (cat_id) REFERENCES categories(cat_id) ON DELETE CASCADE +); + +CREATE INDEX idx_product_categories_category ON product_categories(cat_id); + +-- Create orders table with its indexes +CREATE TABLE orders ( + id BIGSERIAL PRIMARY KEY, + order_number TEXT NOT NULL, + pid BIGINT NOT NULL, + sku TEXT NOT NULL, + date TIMESTAMP WITH TIME ZONE NOT NULL, + price NUMERIC(14, 4) NOT NULL, + quantity INTEGER NOT NULL, + discount NUMERIC(14, 4) DEFAULT 0.0000, + tax NUMERIC(14, 4) DEFAULT 0.0000, + tax_included BOOLEAN DEFAULT false, + shipping NUMERIC(14, 4) DEFAULT 0.0000, + costeach NUMERIC(14, 4) DEFAULT 0.0000, + customer TEXT NOT NULL, + customer_name TEXT, + status TEXT DEFAULT 'pending', + canceled BOOLEAN DEFAULT false, + updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (order_number, pid), + FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE RESTRICT +); + +-- Create trigger for orders +CREATE TRIGGER update_orders_updated + BEFORE UPDATE ON orders + FOR EACH ROW + EXECUTE FUNCTION update_updated_column(); + +CREATE INDEX idx_orders_number ON orders(order_number); +CREATE INDEX idx_orders_pid ON orders(pid); +CREATE INDEX idx_orders_sku ON orders(sku); +CREATE INDEX idx_orders_customer ON orders(customer); +CREATE INDEX idx_orders_date ON orders(date); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_orders_pid_date ON orders(pid, date); +CREATE INDEX idx_orders_updated ON orders(updated); + +-- Create purchase_orders table with its indexes +-- This table now focuses solely on purchase order intent, not receivings +CREATE TABLE purchase_orders ( + id BIGSERIAL PRIMARY KEY, + po_id TEXT NOT NULL, + vendor TEXT NOT NULL, + date TIMESTAMP WITH TIME ZONE NOT NULL, + expected_date DATE, + pid BIGINT NOT NULL, + sku TEXT NOT NULL, + name TEXT NOT NULL, + po_cost_price NUMERIC(14, 4) NOT NULL, + status TEXT DEFAULT 'created', + notes TEXT, + long_note TEXT, + ordered INTEGER NOT NULL, + supplier_id INTEGER, + date_created TIMESTAMP WITH TIME ZONE, + date_ordered TIMESTAMP WITH TIME ZONE, + updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE, + UNIQUE (po_id, pid) +); + +-- Create trigger for purchase_orders +CREATE TRIGGER update_purchase_orders_updated + BEFORE UPDATE ON purchase_orders + FOR EACH ROW + EXECUTE FUNCTION update_updated_column(); + +COMMENT ON COLUMN purchase_orders.name IS 'Product name from products.description'; +COMMENT ON COLUMN purchase_orders.po_cost_price IS 'Original cost from PO'; +COMMENT ON COLUMN purchase_orders.status IS 'canceled, created, electronically_ready_send, ordered, preordered, electronically_sent, receiving_started, done'; + +CREATE INDEX idx_po_id ON purchase_orders(po_id); +CREATE INDEX idx_po_sku ON purchase_orders(sku); +CREATE INDEX idx_po_vendor ON purchase_orders(vendor); +CREATE INDEX idx_po_status ON purchase_orders(status); +CREATE INDEX idx_po_expected_date ON purchase_orders(expected_date); +CREATE INDEX idx_po_pid_status ON purchase_orders(pid, status); +CREATE INDEX idx_po_pid_date ON purchase_orders(pid, date); +CREATE INDEX idx_po_updated ON purchase_orders(updated); +CREATE INDEX idx_po_supplier_id ON purchase_orders(supplier_id); + +-- Create receivings table to track actual receipt of goods +CREATE TABLE receivings ( + id BIGSERIAL PRIMARY KEY, + receiving_id TEXT NOT NULL, + pid BIGINT NOT NULL, + sku TEXT NOT NULL, + name TEXT NOT NULL, + vendor TEXT, + qty_each INTEGER NOT NULL, + qty_each_orig INTEGER, + cost_each NUMERIC(14, 5) NOT NULL, + cost_each_orig NUMERIC(14, 5), + received_by INTEGER, + received_by_name TEXT, + received_date TIMESTAMP WITH TIME ZONE NOT NULL, + receiving_created_date TIMESTAMP WITH TIME ZONE, + supplier_id INTEGER, + status TEXT DEFAULT 'created', + updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE, + UNIQUE (receiving_id, pid) +); + +-- Create trigger for receivings +CREATE TRIGGER update_receivings_updated + BEFORE UPDATE ON receivings + FOR EACH ROW + EXECUTE FUNCTION update_updated_column(); + +COMMENT ON COLUMN receivings.status IS 'canceled, created, partial_received, full_received, paid'; +COMMENT ON COLUMN receivings.qty_each_orig IS 'Original quantity from the source system'; +COMMENT ON COLUMN receivings.cost_each_orig IS 'Original cost from the source system'; +COMMENT ON COLUMN receivings.vendor IS 'Vendor name, same as in purchase_orders'; + +CREATE INDEX idx_receivings_id ON receivings(receiving_id); +CREATE INDEX idx_receivings_pid ON receivings(pid); +CREATE INDEX idx_receivings_sku ON receivings(sku); +CREATE INDEX idx_receivings_status ON receivings(status); +CREATE INDEX idx_receivings_received_date ON receivings(received_date); +CREATE INDEX idx_receivings_supplier_id ON receivings(supplier_id); +CREATE INDEX idx_receivings_vendor ON receivings(vendor); +CREATE INDEX idx_receivings_updated ON receivings(updated); + +SET session_replication_role = 'origin'; -- Re-enable foreign key checks + +-- Create views for common calculations +-- product_sales_trends view moved to metrics-schema.sql + +-- -- Historical data tables imported from production +-- CREATE TABLE imported_product_current_prices ( +-- price_id BIGSERIAL PRIMARY KEY, +-- pid BIGINT NOT NULL, +-- qty_buy SMALLINT NOT NULL, +-- is_min_qty_buy BOOLEAN NOT NULL, +-- price_each NUMERIC(10,3) NOT NULL, +-- qty_limit SMALLINT NOT NULL, +-- no_promo BOOLEAN NOT NULL, +-- checkout_offer BOOLEAN NOT NULL, +-- active BOOLEAN NOT NULL, +-- date_active TIMESTAMP WITH TIME ZONE, +-- date_deactive TIMESTAMP WITH TIME ZONE, +-- updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +-- ); + +-- CREATE INDEX idx_imported_product_current_prices_pid ON imported_product_current_prices(pid, active, qty_buy); +-- CREATE INDEX idx_imported_product_current_prices_checkout ON imported_product_current_prices(checkout_offer, active); +-- CREATE INDEX idx_imported_product_current_prices_deactive ON imported_product_current_prices(date_deactive, active); +-- CREATE INDEX idx_imported_product_current_prices_active ON imported_product_current_prices(date_active, active); + +-- CREATE TABLE imported_daily_inventory ( +-- date DATE NOT NULL, +-- pid BIGINT NOT NULL, +-- amountsold SMALLINT NOT NULL DEFAULT 0, +-- times_sold SMALLINT NOT NULL DEFAULT 0, +-- qtyreceived SMALLINT NOT NULL DEFAULT 0, +-- price NUMERIC(7,2) NOT NULL DEFAULT 0, +-- costeach NUMERIC(7,2) NOT NULL DEFAULT 0, +-- stamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, +-- updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, +-- PRIMARY KEY (date, pid) +-- ); + +-- CREATE INDEX idx_imported_daily_inventory_pid ON imported_daily_inventory(pid); + +-- CREATE TABLE imported_product_stat_history ( +-- pid BIGINT NOT NULL, +-- date DATE NOT NULL, +-- score NUMERIC(10,2) NOT NULL, +-- score2 NUMERIC(10,2) NOT NULL, +-- qty_in_baskets SMALLINT NOT NULL, +-- qty_sold SMALLINT NOT NULL, +-- notifies_set SMALLINT NOT NULL, +-- visibility_score NUMERIC(10,2) NOT NULL, +-- health_score VARCHAR(5) NOT NULL, +-- sold_view_score NUMERIC(6,3) NOT NULL, +-- updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, +-- PRIMARY KEY (pid, date) +-- ); + +-- CREATE INDEX idx_imported_product_stat_history_date ON imported_product_stat_history(date); \ No newline at end of file diff --git a/inventory-server/db/setup-schema.sql b/inventory-server/db/setup-schema.sql new file mode 100644 index 0000000..6c57b1a --- /dev/null +++ b/inventory-server/db/setup-schema.sql @@ -0,0 +1,115 @@ +-- Templates table for storing import templates +CREATE TABLE IF NOT EXISTS templates ( + id SERIAL PRIMARY KEY, + company TEXT NOT NULL, + product_type TEXT NOT NULL, + supplier TEXT, + msrp DECIMAL(10,2), + cost_each DECIMAL(10,2), + qty_per_unit INTEGER, + case_qty INTEGER, + hts_code TEXT, + description TEXT, + weight DECIMAL(10,2), + length DECIMAL(10,2), + width DECIMAL(10,2), + height DECIMAL(10,2), + tax_cat TEXT, + size_cat TEXT, + categories TEXT[], + ship_restrictions TEXT[], + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(company, product_type) +); + +-- AI Prompts table for storing validation prompts +CREATE TABLE IF NOT EXISTS ai_prompts ( + id SERIAL PRIMARY KEY, + prompt_text TEXT NOT NULL, + prompt_type TEXT NOT NULL CHECK (prompt_type IN ('general', 'company_specific', 'system')), + company TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_company_prompt UNIQUE (company), + CONSTRAINT company_required_for_specific CHECK ( + (prompt_type = 'general' AND company IS NULL) OR + (prompt_type = 'system' AND company IS NULL) OR + (prompt_type = 'company_specific' AND company IS NOT NULL) + ) +); + +-- Create a unique partial index to ensure only one general prompt +CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_general_prompt +ON ai_prompts (prompt_type) +WHERE prompt_type = 'general'; + +-- Create a unique partial index to ensure only one system prompt +CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_system_prompt +ON ai_prompts (prompt_type) +WHERE prompt_type = 'system'; + +-- Reusable Images table for storing persistent images +CREATE TABLE IF NOT EXISTS reusable_images ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + image_url TEXT NOT NULL, + is_global BOOLEAN NOT NULL DEFAULT false, + company TEXT, + mime_type TEXT, + file_size INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT company_required_for_non_global CHECK ( + (is_global = true AND company IS NULL) OR + (is_global = false AND company IS NOT NULL) + ) +); + +-- Create index on company for efficient querying +CREATE INDEX IF NOT EXISTS idx_reusable_images_company ON reusable_images(company); +-- Create index on is_global for efficient querying +CREATE INDEX IF NOT EXISTS idx_reusable_images_is_global ON reusable_images(is_global); + +-- AI Validation Performance Tracking +CREATE TABLE IF NOT EXISTS ai_validation_performance ( + id SERIAL PRIMARY KEY, + prompt_length INTEGER NOT NULL, + product_count INTEGER NOT NULL, + start_time TIMESTAMP WITH TIME ZONE NOT NULL, + end_time TIMESTAMP WITH TIME ZONE NOT NULL, + duration_seconds DECIMAL(10,2) GENERATED ALWAYS AS (EXTRACT(EPOCH FROM (end_time - start_time))) STORED, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index on prompt_length for efficient querying +CREATE INDEX IF NOT EXISTS idx_ai_validation_prompt_length ON ai_validation_performance(prompt_length); + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Trigger to automatically update the updated_at column +CREATE TRIGGER update_templates_updated_at + BEFORE UPDATE ON templates + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Trigger to automatically update the updated_at column for ai_prompts +CREATE TRIGGER update_ai_prompts_updated_at + BEFORE UPDATE ON ai_prompts + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Trigger to automatically update the updated_at column for reusable_images +CREATE TRIGGER update_reusable_images_updated_at + BEFORE UPDATE ON reusable_images + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/inventory-server/old/backfill-snapshots.js b/inventory-server/old/backfill-snapshots.js new file mode 100644 index 0000000..455f155 --- /dev/null +++ b/inventory-server/old/backfill-snapshots.js @@ -0,0 +1,426 @@ +const path = require('path'); +const fs = require('fs'); +const progress = require('../scripts/metrics-new/utils/progress'); // Assuming progress utils are here +const { getConnection, closePool } = require('../scripts/metrics-new/utils/db'); // Assuming db utils are here +const os = require('os'); // For detecting number of CPU cores + +// --- Configuration --- +const BATCH_SIZE_DAYS = 1; // Process 1 day per database function call +const SQL_FUNCTION_FILE = path.resolve(__dirname, 'backfill_historical_snapshots.sql'); // Correct path +const LOG_PROGRESS_INTERVAL_MS = 5000; // Update console progress roughly every 5 seconds +const HISTORY_TYPE = 'backfill_snapshots'; // Identifier for history table +const MAX_WORKERS = Math.max(1, Math.floor(os.cpus().length / 2)); // Use half of available CPU cores +const USE_PARALLEL = false; // Set to true to enable parallel processing +const PG_STATEMENT_TIMEOUT_MS = 1800000; // 30 minutes max per query + +// --- Cancellation Handling --- +let isCancelled = false; +let runningQueryPromise = null; // To potentially track the active query + +function requestCancellation() { + if (!isCancelled) { + isCancelled = true; + console.warn('\nCancellation requested. Finishing current batch then stopping...'); + // Note: We are NOT forcefully cancelling the backend query anymore. + } +} + +process.on('SIGINT', requestCancellation); // Handle Ctrl+C +process.on('SIGTERM', requestCancellation); // Handle termination signals + +// --- Main Backfill Function --- +async function backfillSnapshots(cmdStartDate, cmdEndDate, cmdStartBatch = 1) { + let connection; + const overallStartTime = Date.now(); + let calculateHistoryId = null; + let processedDaysTotal = 0; // Track total days processed across all batches executed in this run + let currentBatchNum = cmdStartBatch > 0 ? cmdStartBatch : 1; + let totalBatches = 0; // Initialize totalBatches + let totalDays = 0; // Initialize totalDays + + console.log(`Starting snapshot backfill process...`); + console.log(`SQL Function definition file: ${SQL_FUNCTION_FILE}`); + if (!fs.existsSync(SQL_FUNCTION_FILE)) { + console.error(`FATAL: SQL file not found at ${SQL_FUNCTION_FILE}`); + process.exit(1); // Exit early if file doesn't exist + } + + try { + // Set up a connection with higher memory limits + connection = await getConnection({ + // Add performance-related settings + application_name: 'backfill_snapshots', + statement_timeout: PG_STATEMENT_TIMEOUT_MS, // 30 min timeout per statement + // These parameters may need to be configured in your database: + // work_mem: '1GB', + // maintenance_work_mem: '2GB', + // temp_buffers: '1GB', + }); + + console.log('Database connection acquired.'); + + // --- Ensure Function Exists --- + console.log('Ensuring database function is up-to-date...'); + try { + const sqlFunctionDef = fs.readFileSync(SQL_FUNCTION_FILE, 'utf8'); + if (!sqlFunctionDef.includes('CREATE OR REPLACE FUNCTION backfill_daily_snapshots_range_final')) { + throw new Error(`SQL file ${SQL_FUNCTION_FILE} does not seem to contain the function definition.`); + } + await connection.query(sqlFunctionDef); // Execute the whole file + console.log('Database function `backfill_daily_snapshots_range_final` created/updated.'); + + // Add performance query hints to the database + await connection.query(` + -- Analyze tables for better query planning + ANALYZE public.products; + ANALYZE public.imported_daily_inventory; + ANALYZE public.imported_product_stat_history; + ANALYZE public.daily_product_snapshots; + ANALYZE public.imported_product_current_prices; + `).catch(err => { + // Non-fatal if analyze fails + console.warn('Failed to analyze tables (non-fatal):', err.message); + }); + + } catch (err) { + console.error(`Error processing SQL function file ${SQL_FUNCTION_FILE}:`, err); + throw new Error(`Failed to create or replace DB function: ${err.message}`); + } + + // --- Prepare History Record --- + console.log('Preparing calculation history record...'); + // Ensure history table exists (optional, could be done elsewhere) + await connection.query(` + CREATE TABLE IF NOT EXISTS public.calculate_history ( + id SERIAL PRIMARY KEY, + start_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + end_time TIMESTAMPTZ, + duration_seconds INTEGER, + status VARCHAR(20) NOT NULL, -- e.g., 'running', 'completed', 'failed', 'cancelled' + error_message TEXT, + additional_info JSONB -- Store type, file, batch info etc. + ); + `); + // Mark previous runs of this type as potentially failed if they were left 'running' + await connection.query(` + UPDATE public.calculate_history + SET status = 'failed', error_message = 'Interrupted by new run.' + WHERE status = 'running' AND additional_info->>'type' = $1; + `, [HISTORY_TYPE]); + + // Create new history record + const historyResult = await connection.query(` + INSERT INTO public.calculate_history (start_time, status, additional_info) + VALUES (NOW(), 'running', jsonb_build_object('type', $1::text, 'sql_file', $2::text, 'start_batch', $3::integer)) + RETURNING id; + `, [HISTORY_TYPE, path.basename(SQL_FUNCTION_FILE), cmdStartBatch]); + calculateHistoryId = historyResult.rows[0].id; + console.log(`Calculation history record created with ID: ${calculateHistoryId}`); + + + // --- Determine Date Range --- + console.log('Determining date range...'); + let effectiveStartDate, effectiveEndDate; + + // Use command-line dates if provided, otherwise query DB + if (cmdStartDate) { + effectiveStartDate = cmdStartDate; + } else { + const minDateResult = await connection.query(` + SELECT LEAST( + COALESCE((SELECT MIN(date) FROM public.imported_daily_inventory WHERE date > '1970-01-01'), CURRENT_DATE), + COALESCE((SELECT MIN(date) FROM public.imported_product_stat_history WHERE date > '1970-01-01'), CURRENT_DATE) + )::date as min_date; + `); + effectiveStartDate = minDateResult.rows[0]?.min_date || new Date().toISOString().split('T')[0]; // Fallback + console.log(`Auto-detected start date: ${effectiveStartDate}`); + } + + if (cmdEndDate) { + effectiveEndDate = cmdEndDate; + } else { + const maxDateResult = await connection.query(` + SELECT GREATEST( + COALESCE((SELECT MAX(date) FROM public.imported_daily_inventory WHERE date < CURRENT_DATE), '1970-01-01'::date), + COALESCE((SELECT MAX(date) FROM public.imported_product_stat_history WHERE date < CURRENT_DATE), '1970-01-01'::date) + )::date as max_date; + `); + // Ensure end date is not today or in the future + effectiveEndDate = maxDateResult.rows[0]?.max_date || new Date(Date.now() - 86400000).toISOString().split('T')[0]; // Default yesterday + if (new Date(effectiveEndDate) >= new Date(new Date().toISOString().split('T')[0])) { + effectiveEndDate = new Date(Date.now() - 86400000).toISOString().split('T')[0]; // Set to yesterday if >= today + } + console.log(`Auto-detected end date: ${effectiveEndDate}`); + } + + // Validate dates + const dStart = new Date(effectiveStartDate); + const dEnd = new Date(effectiveEndDate); + if (isNaN(dStart.getTime()) || isNaN(dEnd.getTime()) || dStart > dEnd) { + throw new Error(`Invalid date range: Start "${effectiveStartDate}", End "${effectiveEndDate}"`); + } + + // --- Batch Processing --- + totalDays = Math.ceil((dEnd - dStart) / (1000 * 60 * 60 * 24)) + 1; // Inclusive + totalBatches = Math.ceil(totalDays / BATCH_SIZE_DAYS); + + console.log(`Target Date Range: ${effectiveStartDate} to ${effectiveEndDate} (${totalDays} days)`); + console.log(`Total Batches: ${totalBatches} (Batch Size: ${BATCH_SIZE_DAYS} days)`); + console.log(`Starting from Batch: ${currentBatchNum}`); + + // Initial progress update + progress.outputProgress({ + status: 'running', + operation: 'Starting Batch Processing', + currentBatch: currentBatchNum, + totalBatches: totalBatches, + totalDays: totalDays, + elapsed: '0s', + remaining: 'Calculating...', + rate: 0, + historyId: calculateHistoryId // Include history ID in the object + }); + + while (currentBatchNum <= totalBatches && !isCancelled) { + const batchOffset = (currentBatchNum - 1) * BATCH_SIZE_DAYS; + const batchStartDate = new Date(dStart); + batchStartDate.setDate(dStart.getDate() + batchOffset); + + const batchEndDate = new Date(batchStartDate); + batchEndDate.setDate(batchStartDate.getDate() + BATCH_SIZE_DAYS - 1); + + // Clamp batch end date to the overall effective end date + if (batchEndDate > dEnd) { + batchEndDate.setTime(dEnd.getTime()); + } + + const batchStartDateStr = batchStartDate.toISOString().split('T')[0]; + const batchEndDateStr = batchEndDate.toISOString().split('T')[0]; + const batchStartTime = Date.now(); + + console.log(`\n--- Processing Batch ${currentBatchNum} / ${totalBatches} ---`); + console.log(` Dates: ${batchStartDateStr} to ${batchEndDateStr}`); + + // Execute the function for the batch + try { + progress.outputProgress({ + status: 'running', + operation: `Executing DB function for batch ${currentBatchNum}...`, + currentBatch: currentBatchNum, + totalBatches: totalBatches, + totalDays: totalDays, + elapsed: progress.formatElapsedTime(overallStartTime), + remaining: 'Executing...', + rate: 0, + historyId: calculateHistoryId + }); + + // Performance improvement: Add batch processing hint + await connection.query('SET LOCAL enable_parallel_append = on; SET LOCAL enable_parallel_hash = on; SET LOCAL max_parallel_workers_per_gather = 4;'); + + // Store promise in case we need to try and cancel (though not implemented forcefully) + runningQueryPromise = connection.query( + `SELECT backfill_daily_snapshots_range_final($1::date, $2::date);`, + [batchStartDateStr, batchEndDateStr] + ); + await runningQueryPromise; // Wait for the function call to complete + runningQueryPromise = null; // Clear the promise + + const batchDurationMs = Date.now() - batchStartTime; + const daysInThisBatch = Math.ceil((batchEndDate - batchStartDate) / (1000 * 60 * 60 * 24)) + 1; + processedDaysTotal += daysInThisBatch; + + console.log(` Batch ${currentBatchNum} completed in ${progress.formatElapsedTime(batchStartTime)}.`); + + // --- Update Progress & History --- + const overallElapsedSec = Math.round((Date.now() - overallStartTime) / 1000); + progress.outputProgress({ + status: 'running', + operation: `Completed batch ${currentBatchNum}`, + currentBatch: currentBatchNum, + totalBatches: totalBatches, + totalDays: totalDays, + processedDays: processedDaysTotal, + elapsed: progress.formatElapsedTime(overallStartTime), + remaining: progress.estimateRemaining(overallStartTime, processedDaysTotal, totalDays), + rate: progress.calculateRate(overallStartTime, processedDaysTotal), + batchDuration: progress.formatElapsedTime(batchStartTime), + historyId: calculateHistoryId + }); + + // Save checkpoint in history + await connection.query(` + UPDATE public.calculate_history + SET additional_info = jsonb_set(additional_info, '{last_completed_batch}', $1::jsonb) + || jsonb_build_object('last_processed_date', $2::text) + WHERE id = $3::integer; + `, [JSON.stringify(currentBatchNum), batchEndDateStr, calculateHistoryId]); + + + } catch (batchError) { + console.error(`\n--- ERROR in Batch ${currentBatchNum} (${batchStartDateStr} to ${batchEndDateStr}) ---`); + console.error(' Database Error:', batchError.message); + console.error(' DB Error Code:', batchError.code); + // Log detailed error to history and re-throw to stop the process + await connection.query(` + UPDATE public.calculate_history + SET status = 'failed', + end_time = NOW(), + duration_seconds = $1::integer, + error_message = $2::text, + additional_info = additional_info || jsonb_build_object('failed_batch', $3::integer, 'failed_date_range', $4::text) + WHERE id = $5::integer; + `, [ + Math.round((Date.now() - overallStartTime) / 1000), + `Batch ${currentBatchNum} failed: ${batchError.message} (Code: ${batchError.code || 'N/A'})`, + currentBatchNum, + `${batchStartDateStr} to ${batchEndDateStr}`, + calculateHistoryId + ]); + throw batchError; // Stop execution + } + + currentBatchNum++; + // Optional delay between batches + // await new Promise(resolve => setTimeout(resolve, 500)); + + } // End while loop + + // --- Final Outcome --- + const finalStatus = isCancelled ? 'cancelled' : 'completed'; + const finalMessage = isCancelled ? `Calculation stopped after completing batch ${currentBatchNum - 1}.` : 'Historical snapshots backfill completed successfully.'; + const finalDurationSec = Math.round((Date.now() - overallStartTime) / 1000); + + console.log(`\n--- Backfill ${finalStatus.toUpperCase()} ---`); + console.log(finalMessage); + console.log(`Total duration: ${progress.formatElapsedTime(overallStartTime)}`); + + // Update history record + await connection.query(` + UPDATE public.calculate_history SET status = $1::calculation_status, end_time = NOW(), duration_seconds = $2::integer, error_message = $3 + WHERE id = $4::integer; + `, [finalStatus, finalDurationSec, (isCancelled ? 'User cancelled' : null), calculateHistoryId]); + + if (!isCancelled) { + progress.clearProgress(); // Clear progress state only on successful completion + } else { + progress.outputProgress({ // Final cancelled status update + status: 'cancelled', + operation: finalMessage, + currentBatch: currentBatchNum - 1, + totalBatches: totalBatches, + totalDays: totalDays, + processedDays: processedDaysTotal, + elapsed: progress.formatElapsedTime(overallStartTime), + remaining: 'Cancelled', + rate: 0, + historyId: calculateHistoryId + }); + } + + return { success: true, status: finalStatus, message: finalMessage, duration: finalDurationSec }; + + } catch (error) { + console.error('\n--- Backfill encountered an unrecoverable error ---'); + console.error(error.message); + const finalDurationSec = Math.round((Date.now() - overallStartTime) / 1000); + + // Update history if possible + if (connection && calculateHistoryId) { + try { + await connection.query(` + UPDATE public.calculate_history + SET status = $1::calculation_status, end_time = NOW(), duration_seconds = $2::integer, error_message = $3::text + WHERE id = $4::integer; + `, [ + isCancelled ? 'cancelled' : 'failed', + finalDurationSec, + error.message, + calculateHistoryId + ]); + } catch (histError) { + console.error("Failed to update history record with error state:", histError); + } + } else { + console.error("Could not update history record (no ID or connection)."); + } + + // FIX: Use initialized value or a default if loop never started + const batchNumForError = currentBatchNum > cmdStartBatch ? currentBatchNum - 1 : cmdStartBatch - 1; + + // Update progress.outputProgress call to match actual function signature + try { + // Create progress data object + const progressData = { + status: 'failed', + operation: 'Backfill failed', + message: error.message, + currentBatch: batchNumForError, + totalBatches: totalBatches, + totalDays: totalDays, + processedDays: processedDaysTotal, + elapsed: progress.formatElapsedTime(overallStartTime), + remaining: 'Failed', + rate: 0, + // Include history ID in progress data if needed + historyId: calculateHistoryId + }; + + // Call with single object parameter (not separate historyId) + progress.outputProgress(progressData); + } catch (progressError) { + console.error('Failed to report progress:', progressError); + } + + return { success: false, status: 'failed', error: error.message, duration: finalDurationSec }; + + } finally { + if (connection) { + console.log('Releasing database connection.'); + connection.release(); + } + // Close pool only if this script is meant to be standalone + // If part of a larger app, the app should manage pool closure + // console.log('Closing database pool.'); + // await closePool(); + } +} + +// --- Script Execution --- + +// Parse command-line arguments +const args = process.argv.slice(2); +let cmdStartDateArg, cmdEndDateArg, cmdStartBatchArg = 1; // Default start batch is 1 + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--start-date' && args[i+1]) cmdStartDateArg = args[++i]; + else if (args[i] === '--end-date' && args[i+1]) cmdEndDateArg = args[++i]; + else if (args[i] === '--start-batch' && args[i+1]) cmdStartBatchArg = parseInt(args[++i], 10); +} + +if (isNaN(cmdStartBatchArg) || cmdStartBatchArg < 1) { + console.warn(`Invalid --start-batch value. Defaulting to 1.`); + cmdStartBatchArg = 1; +} + +// Run the backfill process +backfillSnapshots(cmdStartDateArg, cmdEndDateArg, cmdStartBatchArg) + .then(result => { + if (result.success) { + console.log(`\n✅ ${result.message} (Duration: ${result.duration}s)`); + process.exitCode = 0; // Success + } else { + console.error(`\n❌ Backfill failed: ${result.error || 'Unknown error'} (Duration: ${result.duration}s)`); + process.exitCode = 1; // Failure + } + }) + .catch(err => { + console.error('\n❌ Unexpected error during backfill execution:', err); + process.exitCode = 1; // Failure + }) + .finally(async () => { + // Ensure pool is closed if run standalone + console.log('Backfill script finished. Closing pool.'); + await closePool(); // Make sure closePool exists and works in your db utils + process.exit(process.exitCode); // Exit with appropriate code + }); \ No newline at end of file diff --git a/inventory-server/old/backfill_historical_snapshots.sql b/inventory-server/old/backfill_historical_snapshots.sql new file mode 100644 index 0000000..f0c3037 --- /dev/null +++ b/inventory-server/old/backfill_historical_snapshots.sql @@ -0,0 +1,161 @@ +-- Description: Backfills the daily_product_snapshots table using imported historical unit data +-- (daily inventory/stats) and historical price data (current prices table). +-- - Uses imported daily sales/receipt UNIT counts for accuracy. +-- - ESTIMATES historical stock levels using a forward calculation. +-- - APPROXIMATES historical REVENUE using looked-up historical base prices. +-- - APPROXIMATES historical COGS, PROFIT, and STOCK VALUE using CURRENT product costs/prices. +-- Run ONCE after importing historical data and before initial product_metrics population. +-- Dependencies: Core import tables (products), imported history tables (imported_daily_inventory, +-- imported_product_stat_history, imported_product_current_prices), +-- daily_product_snapshots table must exist. +-- Frequency: Run ONCE. + +CREATE OR REPLACE FUNCTION backfill_daily_snapshots_range_final( + _start_date DATE, + _end_date DATE +) +RETURNS VOID AS $$ +DECLARE + _current_processing_date DATE := _start_date; + _batch_start_time TIMESTAMPTZ; + _row_count INTEGER; +BEGIN + RAISE NOTICE 'Starting FINAL historical snapshot backfill from % to %.', _start_date, _end_date; + RAISE NOTICE 'Using historical units and historical prices (for revenue approximation).'; + RAISE NOTICE 'WARNING: Historical COGS, Profit, and Stock Value use CURRENT product costs/prices.'; + + -- Ensure end date is not in the future + IF _end_date >= CURRENT_DATE THEN + _end_date := CURRENT_DATE - INTERVAL '1 day'; + RAISE NOTICE 'Adjusted end date to % to avoid conflict with hourly script.', _end_date; + END IF; + + -- Performance: Create temporary table with product info to avoid repeated lookups + CREATE TEMP TABLE IF NOT EXISTS temp_product_info AS + SELECT + pid, + sku, + COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price, + COALESCE(price, 0.00) as current_price, + COALESCE(regular_price, 0.00) as current_regular_price + FROM public.products; + + -- Performance: Create index on temporary table + CREATE INDEX IF NOT EXISTS temp_product_info_pid_idx ON temp_product_info(pid); + + ANALYZE temp_product_info; + + RAISE NOTICE 'Created temporary product info table with % products', (SELECT COUNT(*) FROM temp_product_info); + + WHILE _current_processing_date <= _end_date LOOP + _batch_start_time := clock_timestamp(); + RAISE NOTICE 'Processing date: %', _current_processing_date; + + -- Get Daily Transaction Unit Info from imported history + WITH DailyHistoryUnits AS ( + SELECT + pids.pid, + -- Prioritize daily_inventory, fallback to product_stat_history for sold qty + COALESCE(di.amountsold, ps.qty_sold, 0)::integer as units_sold_today, + COALESCE(di.qtyreceived, 0)::integer as units_received_today + FROM + (SELECT DISTINCT pid FROM temp_product_info) pids -- Ensure all products are considered + LEFT JOIN public.imported_daily_inventory di + ON pids.pid = di.pid AND di.date = _current_processing_date + LEFT JOIN public.imported_product_stat_history ps + ON pids.pid = ps.pid AND ps.date = _current_processing_date + -- Removed WHERE clause to ensure snapshots are created even for days with 0 activity, + -- allowing stock carry-over. The main query will handle products properly. + ), + HistoricalPrice AS ( + -- Find the base price (qty_buy=1) active on the processing date + SELECT DISTINCT ON (pid) + pid, + price_each + FROM public.imported_product_current_prices + WHERE + qty_buy = 1 + -- Use TIMESTAMPTZ comparison logic: + AND date_active <= (_current_processing_date + interval '1 day' - interval '1 second') -- Active sometime on or before end of processing day + AND (date_deactive IS NULL OR date_deactive > _current_processing_date) -- Not deactivated before start of processing day + -- Assuming 'active' flag isn't needed if dates are correct; add 'AND active != 0' if necessary + ORDER BY + pid, date_active DESC -- Get the most recently activated price + ), + PreviousStock AS ( + -- Get the estimated stock from the PREVIOUS day snapshot + SELECT pid, eod_stock_quantity + FROM public.daily_product_snapshots + WHERE snapshot_date = _current_processing_date - INTERVAL '1 day' + ) + -- Insert into the daily snapshots table + INSERT INTO public.daily_product_snapshots ( + snapshot_date, pid, sku, + eod_stock_quantity, eod_stock_cost, eod_stock_retail, eod_stock_gross, stockout_flag, + units_sold, units_returned, + gross_revenue, discounts, returns_revenue, + net_revenue, cogs, gross_regular_revenue, profit, + units_received, cost_received, + calculation_timestamp + ) + SELECT + _current_processing_date AS snapshot_date, + p.pid, + p.sku, + -- Estimated EOD Stock (using historical daily units) + -- Handle potential NULL from joins with COALESCE 0 + COALESCE(ps.eod_stock_quantity, 0) + COALESCE(dh.units_received_today, 0) - COALESCE(dh.units_sold_today, 0) AS estimated_eod_stock, + -- Valued Stock (using estimated stock and CURRENT prices/costs - APPROXIMATION) + GREATEST(0, COALESCE(ps.eod_stock_quantity, 0) + COALESCE(dh.units_received_today, 0) - COALESCE(dh.units_sold_today, 0)) * p.effective_cost_price AS eod_stock_cost, + GREATEST(0, COALESCE(ps.eod_stock_quantity, 0) + COALESCE(dh.units_received_today, 0) - COALESCE(dh.units_sold_today, 0)) * p.current_price AS eod_stock_retail, -- Stock retail uses current price + GREATEST(0, COALESCE(ps.eod_stock_quantity, 0) + COALESCE(dh.units_received_today, 0) - COALESCE(dh.units_sold_today, 0)) * p.current_regular_price AS eod_stock_gross, -- Stock gross uses current regular price + -- Stockout Flag (based on estimated stock) + (COALESCE(ps.eod_stock_quantity, 0) + COALESCE(dh.units_received_today, 0) - COALESCE(dh.units_sold_today, 0)) <= 0 AS stockout_flag, + + -- Today's Unit Aggregates from History + COALESCE(dh.units_sold_today, 0) as units_sold, + 0 AS units_returned, -- Placeholder: Cannot determine returns from daily summary + + -- Monetary Values using looked-up Historical Price and CURRENT Cost/RegPrice + COALESCE(dh.units_sold_today, 0) * COALESCE(hp.price_each, p.current_price) AS gross_revenue, -- Approx Revenue + 0 AS discounts, -- Placeholder + 0 AS returns_revenue, -- Placeholder + COALESCE(dh.units_sold_today, 0) * COALESCE(hp.price_each, p.current_price) AS net_revenue, -- Approx Net Revenue + COALESCE(dh.units_sold_today, 0) * p.effective_cost_price AS cogs, -- Approx COGS (uses CURRENT cost) + COALESCE(dh.units_sold_today, 0) * p.current_regular_price AS gross_regular_revenue, -- Approx Gross Regular Revenue + -- Approx Profit + (COALESCE(dh.units_sold_today, 0) * COALESCE(hp.price_each, p.current_price)) - (COALESCE(dh.units_sold_today, 0) * p.effective_cost_price) AS profit, + + COALESCE(dh.units_received_today, 0) as units_received, + -- Estimate received cost using CURRENT product cost + COALESCE(dh.units_received_today, 0) * p.effective_cost_price AS cost_received, -- Approx + + clock_timestamp() -- Timestamp of this specific calculation + FROM temp_product_info p -- Use the temp table for better performance + LEFT JOIN PreviousStock ps ON p.pid = ps.pid + LEFT JOIN DailyHistoryUnits dh ON p.pid = dh.pid -- Join today's historical activity + LEFT JOIN HistoricalPrice hp ON p.pid = hp.pid -- Join the looked-up historical price + -- Optimization: Only process products with activity or previous stock + WHERE (dh.units_sold_today > 0 OR dh.units_received_today > 0 OR COALESCE(ps.eod_stock_quantity, 0) > 0) + + ON CONFLICT (snapshot_date, pid) DO NOTHING; -- Avoid errors if rerunning parts, but prefer clean runs + + GET DIAGNOSTICS _row_count = ROW_COUNT; + RAISE NOTICE 'Processed %: Inserted/Skipped % rows. Duration: %', + _current_processing_date, + _row_count, + clock_timestamp() - _batch_start_time; + + _current_processing_date := _current_processing_date + INTERVAL '1 day'; + + END LOOP; + + -- Clean up temporary tables + DROP TABLE IF EXISTS temp_product_info; + + RAISE NOTICE 'Finished FINAL historical snapshot backfill.'; +END; +$$ LANGUAGE plpgsql; + +-- Example usage: +-- SELECT backfill_daily_snapshots_range_final('2023-01-01'::date, '2023-12-31'::date); \ No newline at end of file diff --git a/inventory-server/old/calculate-metrics.js b/inventory-server/old/calculate-metrics.js new file mode 100644 index 0000000..8aaec1f --- /dev/null +++ b/inventory-server/old/calculate-metrics.js @@ -0,0 +1,558 @@ +const path = require('path'); + +// Change working directory to script directory +process.chdir(path.dirname(__filename)); + +require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') }); + +// Configuration flags for controlling which metrics to calculate +// Set to 1 to skip the corresponding calculation, 0 to run it +const SKIP_PRODUCT_METRICS = 0; +const SKIP_TIME_AGGREGATES = 0; +const SKIP_FINANCIAL_METRICS = 0; +const SKIP_VENDOR_METRICS = 0; +const SKIP_CATEGORY_METRICS = 0; +const SKIP_BRAND_METRICS = 0; +const SKIP_SALES_FORECASTS = 0; + +// Add error handler for uncaught exceptions +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + process.exit(1); +}); + +// Add error handler for unhandled promise rejections +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + process.exit(1); +}); + +const progress = require('./metrics/utils/progress'); +console.log('Progress module loaded:', { + modulePath: require.resolve('./metrics/utils/progress'), + exports: Object.keys(progress), + currentDir: process.cwd(), + scriptDir: __dirname +}); + +// Store progress functions in global scope to ensure availability +global.formatElapsedTime = progress.formatElapsedTime; +global.estimateRemaining = progress.estimateRemaining; +global.calculateRate = progress.calculateRate; +global.outputProgress = progress.outputProgress; +global.clearProgress = progress.clearProgress; +global.getProgress = progress.getProgress; +global.logError = progress.logError; + +// List of temporary tables used in the calculation process +const TEMP_TABLES = [ + 'temp_revenue_ranks', + 'temp_sales_metrics', + 'temp_purchase_metrics', + 'temp_product_metrics', + 'temp_vendor_metrics', + 'temp_category_metrics', + 'temp_brand_metrics', + 'temp_forecast_dates', + 'temp_daily_sales', + 'temp_product_stats', + 'temp_category_sales', + 'temp_category_stats', + 'temp_beginning_inventory', + 'temp_monthly_inventory' +]; + +// Add cleanup function for temporary tables +async function cleanupTemporaryTables(connection) { + try { + // Drop each temporary table if it exists + for (const table of TEMP_TABLES) { + await connection.query(`DROP TABLE IF EXISTS ${table}`); + } + } catch (err) { + console.error('Error cleaning up temporary tables:', err); + } +} + +const { getConnection, closePool } = require('./metrics/utils/db'); +const calculateProductMetrics = require('./metrics/product-metrics'); +const calculateTimeAggregates = require('./metrics/time-aggregates'); +const calculateFinancialMetrics = require('./metrics/financial-metrics'); +const calculateVendorMetrics = require('./metrics/vendor-metrics'); +const calculateCategoryMetrics = require('./metrics/category-metrics'); +const calculateBrandMetrics = require('./metrics/brand-metrics'); +const calculateSalesForecasts = require('./metrics/sales-forecasts'); + +// Add cancel handler +let isCancelled = false; + +function cancelCalculation() { + isCancelled = true; + console.log('Calculation has been cancelled by user'); + + // Force-terminate any query that's been running for more than 5 seconds + try { + const connection = getConnection(); + connection.then(async (conn) => { + try { + // Identify and terminate long-running queries from our application + await conn.query(` + SELECT pg_cancel_backend(pid) + FROM pg_stat_activity + WHERE query_start < now() - interval '5 seconds' + AND application_name LIKE '%node%' + AND query NOT LIKE '%pg_cancel_backend%' + `); + + // Clean up any temporary tables + await cleanupTemporaryTables(conn); + + // Release connection + conn.release(); + } catch (err) { + console.error('Error during force cancellation:', err); + conn.release(); + } + }).catch(err => { + console.error('Could not get connection for cancellation:', err); + }); + } catch (err) { + console.error('Failed to terminate running queries:', err); + } + + return { + success: true, + message: 'Calculation has been cancelled' + }; +} + +// Handle SIGTERM signal for cancellation +process.on('SIGTERM', cancelCalculation); + +// Update the main calculation function to use the new modular structure +async function calculateMetrics() { + let connection; + const startTime = Date.now(); + let processedProducts = 0; + let processedOrders = 0; + let processedPurchaseOrders = 0; + let totalProducts = 0; + let totalOrders = 0; + let totalPurchaseOrders = 0; + let calculateHistoryId; + + // Set a maximum execution time (30 minutes) + const MAX_EXECUTION_TIME = 30 * 60 * 1000; + const timeout = setTimeout(() => { + console.error(`Calculation timed out after ${MAX_EXECUTION_TIME/1000} seconds, forcing termination`); + // Call cancel and force exit + cancelCalculation(); + process.exit(1); + }, MAX_EXECUTION_TIME); + + try { + // Clean up any previously running calculations + connection = await getConnection(); + await connection.query(` + UPDATE calculate_history + SET + status = 'cancelled', + end_time = NOW(), + duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER, + error_message = 'Previous calculation was not completed properly' + WHERE status = 'running' + `); + + // Get counts from all relevant tables + const [productCountResult, orderCountResult, poCountResult] = await Promise.all([ + connection.query('SELECT COUNT(*) as total FROM products'), + connection.query('SELECT COUNT(*) as total FROM orders'), + connection.query('SELECT COUNT(*) as total FROM purchase_orders') + ]); + + totalProducts = parseInt(productCountResult.rows[0].total); + totalOrders = parseInt(orderCountResult.rows[0].total); + totalPurchaseOrders = parseInt(poCountResult.rows[0].total); + + // Create history record for this calculation + const historyResult = await connection.query(` + INSERT INTO calculate_history ( + start_time, + status, + total_products, + total_orders, + total_purchase_orders, + additional_info + ) VALUES ( + NOW(), + 'running', + $1, + $2, + $3, + jsonb_build_object( + 'skip_product_metrics', ($4::int > 0), + 'skip_time_aggregates', ($5::int > 0), + 'skip_financial_metrics', ($6::int > 0), + 'skip_vendor_metrics', ($7::int > 0), + 'skip_category_metrics', ($8::int > 0), + 'skip_brand_metrics', ($9::int > 0), + 'skip_sales_forecasts', ($10::int > 0) + ) + ) RETURNING id + `, [ + totalProducts, + totalOrders, + totalPurchaseOrders, + SKIP_PRODUCT_METRICS, + SKIP_TIME_AGGREGATES, + SKIP_FINANCIAL_METRICS, + SKIP_VENDOR_METRICS, + SKIP_CATEGORY_METRICS, + SKIP_BRAND_METRICS, + SKIP_SALES_FORECASTS + ]); + calculateHistoryId = historyResult.rows[0].id; + + // Add debug logging for the progress functions + console.log('Debug - Progress functions:', { + formatElapsedTime: typeof global.formatElapsedTime, + estimateRemaining: typeof global.estimateRemaining, + calculateRate: typeof global.calculateRate, + startTime: startTime + }); + + try { + const elapsed = global.formatElapsedTime(startTime); + console.log('Debug - formatElapsedTime test successful:', elapsed); + } catch (err) { + console.error('Debug - Error testing formatElapsedTime:', err); + throw err; + } + + // Release the connection before getting a new one + connection.release(); + isCancelled = false; + connection = await getConnection(); + + try { + global.outputProgress({ + status: 'running', + operation: 'Starting metrics calculation', + current: 0, + total: 100, + elapsed: '0s', + remaining: 'Calculating...', + rate: 0, + percentage: '0', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Update progress periodically + const updateProgress = async (products = null, orders = null, purchaseOrders = null) => { + // Ensure all values are valid numbers or default to previous value + if (products !== null) processedProducts = Number(products) || processedProducts || 0; + if (orders !== null) processedOrders = Number(orders) || processedOrders || 0; + if (purchaseOrders !== null) processedPurchaseOrders = Number(purchaseOrders) || processedPurchaseOrders || 0; + + // Ensure we never send NaN to the database + const safeProducts = Number(processedProducts) || 0; + const safeOrders = Number(processedOrders) || 0; + const safePurchaseOrders = Number(processedPurchaseOrders) || 0; + + await connection.query(` + UPDATE calculate_history + SET + processed_products = $1, + processed_orders = $2, + processed_purchase_orders = $3 + WHERE id = $4 + `, [safeProducts, safeOrders, safePurchaseOrders, calculateHistoryId]); + }; + + // Helper function to ensure valid progress numbers + const ensureValidProgress = (current, total) => ({ + current: Number(current) || 0, + total: Number(total) || 1, // Default to 1 to avoid division by zero + percentage: (((Number(current) || 0) / (Number(total) || 1)) * 100).toFixed(1) + }); + + // Initial progress + const initialProgress = ensureValidProgress(0, totalProducts); + global.outputProgress({ + status: 'running', + operation: 'Starting metrics calculation', + current: initialProgress.current, + total: initialProgress.total, + elapsed: '0s', + remaining: 'Calculating...', + rate: 0, + percentage: initialProgress.percentage, + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (!SKIP_PRODUCT_METRICS) { + const result = await calculateProductMetrics(startTime, totalProducts); + await updateProgress(result.processedProducts, result.processedOrders, result.processedPurchaseOrders); + if (!result.success) { + throw new Error('Product metrics calculation failed'); + } + } else { + console.log('Skipping product metrics calculation...'); + processedProducts = Math.floor(totalProducts * 0.6); + await updateProgress(processedProducts); + global.outputProgress({ + status: 'running', + operation: 'Skipping product metrics calculation', + current: processedProducts, + total: totalProducts, + elapsed: global.formatElapsedTime(startTime), + remaining: global.estimateRemaining(startTime, processedProducts, totalProducts), + rate: global.calculateRate(startTime, processedProducts), + percentage: '60', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + } + + // Calculate time-based aggregates + if (!SKIP_TIME_AGGREGATES) { + const result = await calculateTimeAggregates(startTime, totalProducts, processedProducts); + await updateProgress(result.processedProducts, result.processedOrders, result.processedPurchaseOrders); + if (!result.success) { + throw new Error('Time aggregates calculation failed'); + } + } else { + console.log('Skipping time aggregates calculation'); + } + + // Calculate financial metrics + if (!SKIP_FINANCIAL_METRICS) { + const result = await calculateFinancialMetrics(startTime, totalProducts, processedProducts); + await updateProgress(result.processedProducts, result.processedOrders, result.processedPurchaseOrders); + if (!result.success) { + throw new Error('Financial metrics calculation failed'); + } + } else { + console.log('Skipping financial metrics calculation'); + } + + // Calculate vendor metrics + if (!SKIP_VENDOR_METRICS) { + const result = await calculateVendorMetrics(startTime, totalProducts, processedProducts); + await updateProgress(result.processedProducts, result.processedOrders, result.processedPurchaseOrders); + if (!result.success) { + throw new Error('Vendor metrics calculation failed'); + } + } else { + console.log('Skipping vendor metrics calculation'); + } + + // Calculate category metrics + if (!SKIP_CATEGORY_METRICS) { + const result = await calculateCategoryMetrics(startTime, totalProducts, processedProducts); + await updateProgress(result.processedProducts, result.processedOrders, result.processedPurchaseOrders); + if (!result.success) { + throw new Error('Category metrics calculation failed'); + } + } else { + console.log('Skipping category metrics calculation'); + } + + // Calculate brand metrics + if (!SKIP_BRAND_METRICS) { + const result = await calculateBrandMetrics(startTime, totalProducts, processedProducts); + await updateProgress(result.processedProducts, result.processedOrders, result.processedPurchaseOrders); + if (!result.success) { + throw new Error('Brand metrics calculation failed'); + } + } else { + console.log('Skipping brand metrics calculation'); + } + + // Calculate sales forecasts + if (!SKIP_SALES_FORECASTS) { + const result = await calculateSalesForecasts(startTime, totalProducts, processedProducts); + await updateProgress(result.processedProducts, result.processedOrders, result.processedPurchaseOrders); + if (!result.success) { + throw new Error('Sales forecasts calculation failed'); + } + } else { + console.log('Skipping sales forecasts calculation'); + } + + // Final progress update with guaranteed valid numbers + const finalProgress = ensureValidProgress(totalProducts, totalProducts); + + // Final success message + outputProgress({ + status: 'complete', + operation: 'Metrics calculation complete', + current: finalProgress.current, + total: finalProgress.total, + elapsed: global.formatElapsedTime(startTime), + remaining: '0s', + rate: global.calculateRate(startTime, finalProgress.current), + percentage: '100', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Ensure all values are valid numbers before final update + const finalStats = { + processedProducts: Number(processedProducts) || 0, + processedOrders: Number(processedOrders) || 0, + processedPurchaseOrders: Number(processedPurchaseOrders) || 0 + }; + + // Update history with completion + await connection.query(` + UPDATE calculate_history + SET + end_time = NOW(), + duration_seconds = $1, + processed_products = $2, + processed_orders = $3, + processed_purchase_orders = $4, + status = 'completed' + WHERE id = $5 + `, [Math.round((Date.now() - startTime) / 1000), + finalStats.processedProducts, + finalStats.processedOrders, + finalStats.processedPurchaseOrders, + calculateHistoryId]); + + // Clear progress file on successful completion + global.clearProgress(); + + return { + success: true, + message: 'Calculation completed successfully', + duration: Math.round((Date.now() - startTime) / 1000) + }; + } catch (error) { + const endTime = Date.now(); + const totalElapsedSeconds = Math.round((endTime - startTime) / 1000); + + // Update history with error + await connection.query(` + UPDATE calculate_history + SET + end_time = NOW(), + duration_seconds = $1, + processed_products = $2, + processed_orders = $3, + processed_purchase_orders = $4, + status = $5, + error_message = $6 + WHERE id = $7 + `, [ + totalElapsedSeconds, + processedProducts || 0, // Ensure we have a valid number + processedOrders || 0, // Ensure we have a valid number + processedPurchaseOrders || 0, // Ensure we have a valid number + isCancelled ? 'cancelled' : 'failed', + error.message, + calculateHistoryId + ]); + + if (isCancelled) { + global.outputProgress({ + status: 'cancelled', + operation: 'Calculation cancelled', + current: processedProducts, + total: totalProducts || 0, + elapsed: global.formatElapsedTime(startTime), + remaining: null, + rate: global.calculateRate(startTime, processedProducts), + percentage: ((processedProducts / (totalProducts || 1)) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + } else { + global.outputProgress({ + status: 'error', + operation: 'Error: ' + error.message, + current: processedProducts, + total: totalProducts || 0, + elapsed: global.formatElapsedTime(startTime), + remaining: null, + rate: global.calculateRate(startTime, processedProducts), + percentage: ((processedProducts / (totalProducts || 1)) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + } + throw error; + } finally { + // Clear the timeout to prevent forced termination + clearTimeout(timeout); + + // Always clean up and release connection + if (connection) { + try { + await cleanupTemporaryTables(connection); + connection.release(); + } catch (err) { + console.error('Error in final cleanup:', err); + } + } + } + } catch (error) { + console.error('Error in metrics calculation', error); + + try { + if (connection) { + await connection.query(` + UPDATE calculate_history + SET + status = 'failed', + end_time = NOW(), + duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER, + error_message = $1 + WHERE id = $2 + `, [error.message.substring(0, 500), calculateHistoryId]); + } + } catch (updateError) { + console.error('Error updating calculation history:', updateError); + } + + throw error; + } +} + +// Export as a module with all necessary functions +module.exports = { + calculateMetrics, + cancelCalculation, + getProgress: global.getProgress +}; + +// Run directly if called from command line +if (require.main === module) { + calculateMetrics().catch(error => { + if (!error.message.includes('Operation cancelled')) { + console.error('Error:', error); + } + process.exit(1); + }); +} \ No newline at end of file diff --git a/inventory-server/old/config-schema.sql b/inventory-server/old/config-schema.sql new file mode 100644 index 0000000..857acf9 --- /dev/null +++ b/inventory-server/old/config-schema.sql @@ -0,0 +1,242 @@ +-- -- Configuration tables schema + + + +-- -- Stock threshold configurations +-- CREATE TABLE stock_thresholds ( +-- id INTEGER NOT NULL, +-- category_id BIGINT, -- NULL means default/global threshold +-- vendor VARCHAR(100), -- NULL means applies to all vendors +-- critical_days INTEGER NOT NULL DEFAULT 7, +-- reorder_days INTEGER NOT NULL DEFAULT 14, +-- overstock_days INTEGER NOT NULL DEFAULT 90, +-- low_stock_threshold INTEGER NOT NULL DEFAULT 5, +-- min_reorder_quantity INTEGER NOT NULL DEFAULT 1, +-- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- PRIMARY KEY (id), +-- FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, +-- UNIQUE (category_id, vendor) +-- ); + +-- CREATE TRIGGER update_stock_thresholds_updated +-- BEFORE UPDATE ON stock_thresholds +-- FOR EACH ROW +-- EXECUTE FUNCTION update_updated_at_column(); + +-- CREATE INDEX idx_st_metrics ON stock_thresholds(category_id, vendor); + +-- -- Lead time threshold configurations +-- CREATE TABLE lead_time_thresholds ( +-- id INTEGER NOT NULL, +-- category_id BIGINT, -- NULL means default/global threshold +-- vendor VARCHAR(100), -- NULL means applies to all vendors +-- target_days INTEGER NOT NULL DEFAULT 14, +-- warning_days INTEGER NOT NULL DEFAULT 21, +-- critical_days INTEGER NOT NULL DEFAULT 30, +-- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- PRIMARY KEY (id), +-- FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, +-- UNIQUE (category_id, vendor) +-- ); + +-- CREATE TRIGGER update_lead_time_thresholds_updated +-- BEFORE UPDATE ON lead_time_thresholds +-- FOR EACH ROW +-- EXECUTE FUNCTION update_updated_at_column(); + +-- -- Sales velocity window configurations +-- CREATE TABLE sales_velocity_config ( +-- id INTEGER NOT NULL, +-- category_id BIGINT, -- NULL means default/global threshold +-- vendor VARCHAR(100), -- NULL means applies to all vendors +-- daily_window_days INTEGER NOT NULL DEFAULT 30, +-- weekly_window_days INTEGER NOT NULL DEFAULT 7, +-- monthly_window_days INTEGER NOT NULL DEFAULT 90, +-- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- PRIMARY KEY (id), +-- FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, +-- UNIQUE (category_id, vendor) +-- ); + +-- CREATE TRIGGER update_sales_velocity_config_updated +-- BEFORE UPDATE ON sales_velocity_config +-- FOR EACH ROW +-- EXECUTE FUNCTION update_updated_at_column(); + +-- CREATE INDEX idx_sv_metrics ON sales_velocity_config(category_id, vendor); + +-- -- ABC Classification configurations +-- CREATE TABLE abc_classification_config ( +-- id INTEGER NOT NULL PRIMARY KEY, +-- a_threshold DECIMAL(5,2) NOT NULL DEFAULT 20.0, +-- b_threshold DECIMAL(5,2) NOT NULL DEFAULT 50.0, +-- classification_period_days INTEGER NOT NULL DEFAULT 90, +-- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +-- ); + +-- CREATE TRIGGER update_abc_classification_config_updated +-- BEFORE UPDATE ON abc_classification_config +-- FOR EACH ROW +-- EXECUTE FUNCTION update_updated_at_column(); + +-- -- Safety stock configurations +-- CREATE TABLE safety_stock_config ( +-- id INTEGER NOT NULL, +-- category_id BIGINT, -- NULL means default/global threshold +-- vendor VARCHAR(100), -- NULL means applies to all vendors +-- coverage_days INTEGER NOT NULL DEFAULT 14, +-- service_level DECIMAL(5,2) NOT NULL DEFAULT 95.0, +-- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- PRIMARY KEY (id), +-- FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, +-- UNIQUE (category_id, vendor) +-- ); + +-- CREATE TRIGGER update_safety_stock_config_updated +-- BEFORE UPDATE ON safety_stock_config +-- FOR EACH ROW +-- EXECUTE FUNCTION update_updated_at_column(); + +-- CREATE INDEX idx_ss_metrics ON safety_stock_config(category_id, vendor); + +-- -- Turnover rate configurations +-- CREATE TABLE turnover_config ( +-- id INTEGER NOT NULL, +-- category_id BIGINT, -- NULL means default/global threshold +-- vendor VARCHAR(100), -- NULL means applies to all vendors +-- calculation_period_days INTEGER NOT NULL DEFAULT 30, +-- target_rate DECIMAL(10,2) NOT NULL DEFAULT 1.0, +-- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- PRIMARY KEY (id), +-- FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, +-- UNIQUE (category_id, vendor) +-- ); + +-- CREATE TRIGGER update_turnover_config_updated +-- BEFORE UPDATE ON turnover_config +-- FOR EACH ROW +-- EXECUTE FUNCTION update_updated_at_column(); + +-- -- Create table for sales seasonality factors +-- CREATE TABLE sales_seasonality ( +-- month INTEGER NOT NULL, +-- seasonality_factor DECIMAL(5,3) DEFAULT 0, +-- last_updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, +-- PRIMARY KEY (month), +-- CONSTRAINT month_range CHECK (month BETWEEN 1 AND 12), +-- CONSTRAINT seasonality_range CHECK (seasonality_factor BETWEEN -1.0 AND 1.0) +-- ); + +-- CREATE TRIGGER update_sales_seasonality_updated +-- BEFORE UPDATE ON sales_seasonality +-- FOR EACH ROW +-- EXECUTE FUNCTION update_updated_at_column(); + +-- -- Create table for financial calculation parameters +-- CREATE TABLE financial_calc_config ( +-- id INTEGER NOT NULL PRIMARY KEY, +-- order_cost DECIMAL(10,2) NOT NULL DEFAULT 25.00, -- The fixed cost per purchase order (used in EOQ) +-- holding_rate DECIMAL(10,4) NOT NULL DEFAULT 0.25, -- The annual inventory holding cost as a percentage of unit cost (used in EOQ) +-- service_level_z_score DECIMAL(10,4) NOT NULL DEFAULT 1.96, -- Z-score for ~95% service level (used in Safety Stock) +-- min_reorder_qty INTEGER NOT NULL DEFAULT 1, -- Minimum reorder quantity +-- default_reorder_qty INTEGER NOT NULL DEFAULT 5, -- Default reorder quantity when sales data is insufficient +-- default_safety_stock INTEGER NOT NULL DEFAULT 5, -- Default safety stock when sales data is insufficient +-- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +-- ); + +-- CREATE TRIGGER update_financial_calc_config_updated +-- BEFORE UPDATE ON financial_calc_config +-- FOR EACH ROW +-- EXECUTE FUNCTION update_updated_at_column(); + +-- -- Insert default global thresholds +-- INSERT INTO stock_thresholds (id, category_id, vendor, critical_days, reorder_days, overstock_days) +-- VALUES (1, NULL, NULL, 7, 14, 90) +-- ON CONFLICT (id) DO UPDATE SET +-- critical_days = EXCLUDED.critical_days, +-- reorder_days = EXCLUDED.reorder_days, +-- overstock_days = EXCLUDED.overstock_days; + +-- INSERT INTO lead_time_thresholds (id, category_id, vendor, target_days, warning_days, critical_days) +-- VALUES (1, NULL, NULL, 14, 21, 30) +-- ON CONFLICT (id) DO UPDATE SET +-- target_days = EXCLUDED.target_days, +-- warning_days = EXCLUDED.warning_days, +-- critical_days = EXCLUDED.critical_days; + +-- INSERT INTO sales_velocity_config (id, category_id, vendor, daily_window_days, weekly_window_days, monthly_window_days) +-- VALUES (1, NULL, NULL, 30, 7, 90) +-- ON CONFLICT (id) DO UPDATE SET +-- daily_window_days = EXCLUDED.daily_window_days, +-- weekly_window_days = EXCLUDED.weekly_window_days, +-- monthly_window_days = EXCLUDED.monthly_window_days; + +-- INSERT INTO abc_classification_config (id, a_threshold, b_threshold, classification_period_days) +-- VALUES (1, 20.0, 50.0, 90) +-- ON CONFLICT (id) DO UPDATE SET +-- a_threshold = EXCLUDED.a_threshold, +-- b_threshold = EXCLUDED.b_threshold, +-- classification_period_days = EXCLUDED.classification_period_days; + +-- INSERT INTO safety_stock_config (id, category_id, vendor, coverage_days, service_level) +-- VALUES (1, NULL, NULL, 14, 95.0) +-- ON CONFLICT (id) DO UPDATE SET +-- coverage_days = EXCLUDED.coverage_days, +-- service_level = EXCLUDED.service_level; + +-- INSERT INTO turnover_config (id, category_id, vendor, calculation_period_days, target_rate) +-- VALUES (1, NULL, NULL, 30, 1.0) +-- ON CONFLICT (id) DO UPDATE SET +-- calculation_period_days = EXCLUDED.calculation_period_days, +-- target_rate = EXCLUDED.target_rate; + +-- -- Insert default seasonality factors (neutral) +-- INSERT INTO sales_seasonality (month, seasonality_factor) +-- VALUES +-- (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), +-- (7, 0), (8, 0), (9, 0), (10, 0), (11, 0), (12, 0) +-- ON CONFLICT (month) DO UPDATE SET +-- last_updated = CURRENT_TIMESTAMP; + +-- -- Insert default values +-- INSERT INTO financial_calc_config (id, order_cost, holding_rate, service_level_z_score, min_reorder_qty, default_reorder_qty, default_safety_stock) +-- VALUES (1, 25.00, 0.25, 1.96, 1, 5, 5) +-- ON CONFLICT (id) DO UPDATE SET +-- order_cost = EXCLUDED.order_cost, +-- holding_rate = EXCLUDED.holding_rate, +-- service_level_z_score = EXCLUDED.service_level_z_score, +-- min_reorder_qty = EXCLUDED.min_reorder_qty, +-- default_reorder_qty = EXCLUDED.default_reorder_qty, +-- default_safety_stock = EXCLUDED.default_safety_stock; + +-- -- View to show thresholds with category names +-- CREATE OR REPLACE VIEW stock_thresholds_view AS +-- SELECT +-- st.*, +-- c.name as category_name, +-- CASE +-- WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 'Global Default' +-- WHEN st.category_id IS NULL THEN 'Vendor: ' || st.vendor +-- WHEN st.vendor IS NULL THEN 'Category: ' || c.name +-- ELSE 'Category: ' || c.name || ' / Vendor: ' || st.vendor +-- END as threshold_scope +-- FROM +-- stock_thresholds st +-- LEFT JOIN +-- categories c ON st.category_id = c.cat_id +-- ORDER BY +-- CASE +-- WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 1 +-- WHEN st.category_id IS NULL THEN 2 +-- WHEN st.vendor IS NULL THEN 3 +-- ELSE 4 +-- END, +-- c.name, +-- st.vendor; \ No newline at end of file diff --git a/inventory-server/old/historical-data.js b/inventory-server/old/historical-data.js new file mode 100644 index 0000000..ae34f24 --- /dev/null +++ b/inventory-server/old/historical-data.js @@ -0,0 +1,961 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('../scripts/metrics-new/utils/progress'); +const fs = require('fs'); +const path = require('path'); +const { pipeline } = require('stream'); +const { promisify } = require('util'); + +// Configuration constants to control which tables get imported +const IMPORT_PRODUCT_CURRENT_PRICES = false; +const IMPORT_DAILY_INVENTORY = false; +const IMPORT_PRODUCT_STAT_HISTORY = true; + +// For product stat history, limit to more recent data for faster initial import +const USE_RECENT_MONTHS = 12; // Just use the most recent months for product_stat_history + +/** + * Validates a date from MySQL before inserting it into PostgreSQL + * @param {string|Date|null} mysqlDate - Date string or object from MySQL + * @returns {string|null} Valid date string or null if invalid + */ +function validateDate(mysqlDate) { + // Handle null, undefined, or empty values + if (!mysqlDate) { + return null; + } + + // Convert to string if it's not already + const dateStr = String(mysqlDate); + + // Handle MySQL zero dates and empty values + if (dateStr === '0000-00-00' || + dateStr === '0000-00-00 00:00:00' || + dateStr.indexOf('0000-00-00') !== -1 || + dateStr === '') { + return null; + } + + // Check if the date is valid + const date = new Date(mysqlDate); + + // If the date is invalid or suspiciously old (pre-1970), return null + if (isNaN(date.getTime()) || date.getFullYear() < 1970) { + return null; + } + + return mysqlDate; +} + +/** + * Imports historical data from MySQL to PostgreSQL + */ +async function importHistoricalData( + prodConnection, + localConnection, + options = {} +) { + const { + incrementalUpdate = true, + oneYearAgo = new Date(new Date().setFullYear(new Date().getFullYear() - 1)) + } = options; + + const oneYearAgoStr = oneYearAgo.toISOString().split('T')[0]; + const startTime = Date.now(); + + // Use larger batch sizes to improve performance + const BATCH_SIZE = 5000; // For fetching from small tables + const INSERT_BATCH_SIZE = 500; // For inserting to small tables + const LARGE_BATCH_SIZE = 10000; // For fetching from large tables + const LARGE_INSERT_BATCH_SIZE = 1000; // For inserting to large tables + + // Calculate date for recent data + const recentDateStr = new Date( + new Date().setMonth(new Date().getMonth() - USE_RECENT_MONTHS) + ).toISOString().split('T')[0]; + + console.log(`Starting import with: + - One year ago date: ${oneYearAgoStr} + - Recent months date: ${recentDateStr} (for product_stat_history) + - Incremental update: ${incrementalUpdate} + - Standard batch size: ${BATCH_SIZE} + - Standard insert batch size: ${INSERT_BATCH_SIZE} + - Large table batch size: ${LARGE_BATCH_SIZE} + - Large table insert batch size: ${LARGE_INSERT_BATCH_SIZE} + - Import product_current_prices: ${IMPORT_PRODUCT_CURRENT_PRICES} + - Import daily_inventory: ${IMPORT_DAILY_INVENTORY} + - Import product_stat_history: ${IMPORT_PRODUCT_STAT_HISTORY}`); + + try { + // Get last sync time for incremental updates + const lastSyncTimes = {}; + + if (incrementalUpdate) { + try { + const syncResult = await localConnection.query(` + SELECT table_name, last_sync_timestamp + FROM sync_status + WHERE table_name IN ( + 'imported_product_current_prices', + 'imported_daily_inventory', + 'imported_product_stat_history' + ) + `); + + // Add check for rows existence and type + if (syncResult && Array.isArray(syncResult.rows)) { + for (const row of syncResult.rows) { + lastSyncTimes[row.table_name] = row.last_sync_timestamp; + console.log(`Last sync time for ${row.table_name}: ${row.last_sync_timestamp}`); + } + } else { + console.warn('Sync status query did not return expected rows. Proceeding without last sync times.'); + } + } catch (error) { + console.error('Error fetching sync status:', error); + } + } + + // Determine how many tables will be imported + const tablesCount = [ + IMPORT_PRODUCT_CURRENT_PRICES, + IMPORT_DAILY_INVENTORY, + IMPORT_PRODUCT_STAT_HISTORY + ].filter(Boolean).length; + + // Run all imports sequentially for better reliability + console.log(`Starting sequential imports for ${tablesCount} tables...`); + outputProgress({ + status: "running", + operation: "Historical data import", + message: `Starting sequential imports for ${tablesCount} tables...`, + current: 0, + total: tablesCount, + elapsed: formatElapsedTime(startTime) + }); + + let progressCount = 0; + let productCurrentPricesResult = { recordsAdded: 0, recordsUpdated: 0, totalProcessed: 0, errors: [] }; + let dailyInventoryResult = { recordsAdded: 0, recordsUpdated: 0, totalProcessed: 0, errors: [] }; + let productStatHistoryResult = { recordsAdded: 0, recordsUpdated: 0, totalProcessed: 0, errors: [] }; + + // Import product current prices + if (IMPORT_PRODUCT_CURRENT_PRICES) { + console.log('Importing product current prices...'); + productCurrentPricesResult = await importProductCurrentPrices( + prodConnection, + localConnection, + oneYearAgoStr, + lastSyncTimes['imported_product_current_prices'], + BATCH_SIZE, + INSERT_BATCH_SIZE, + incrementalUpdate, + startTime + ); + progressCount++; + + outputProgress({ + status: "running", + operation: "Historical data import", + message: `Completed import ${progressCount} of ${tablesCount}`, + current: progressCount, + total: tablesCount, + elapsed: formatElapsedTime(startTime) + }); + } + + // Import daily inventory + if (IMPORT_DAILY_INVENTORY) { + console.log('Importing daily inventory...'); + dailyInventoryResult = await importDailyInventory( + prodConnection, + localConnection, + oneYearAgoStr, + lastSyncTimes['imported_daily_inventory'], + BATCH_SIZE, + INSERT_BATCH_SIZE, + incrementalUpdate, + startTime + ); + progressCount++; + + outputProgress({ + status: "running", + operation: "Historical data import", + message: `Completed import ${progressCount} of ${tablesCount}`, + current: progressCount, + total: tablesCount, + elapsed: formatElapsedTime(startTime) + }); + } + + // Import product stat history - using optimized approach + if (IMPORT_PRODUCT_STAT_HISTORY) { + console.log('Importing product stat history...'); + productStatHistoryResult = await importProductStatHistory( + prodConnection, + localConnection, + recentDateStr, // Use more recent date for this massive table + lastSyncTimes['imported_product_stat_history'], + LARGE_BATCH_SIZE, + LARGE_INSERT_BATCH_SIZE, + incrementalUpdate, + startTime, + USE_RECENT_MONTHS // Pass the recent months constant + ); + progressCount++; + + outputProgress({ + status: "running", + operation: "Historical data import", + message: `Completed import ${progressCount} of ${tablesCount}`, + current: progressCount, + total: tablesCount, + elapsed: formatElapsedTime(startTime) + }); + } + + // Aggregate results + const totalRecordsAdded = + productCurrentPricesResult.recordsAdded + + dailyInventoryResult.recordsAdded + + productStatHistoryResult.recordsAdded; + + const totalRecordsUpdated = + productCurrentPricesResult.recordsUpdated + + dailyInventoryResult.recordsUpdated + + productStatHistoryResult.recordsUpdated; + + const totalProcessed = + productCurrentPricesResult.totalProcessed + + dailyInventoryResult.totalProcessed + + productStatHistoryResult.totalProcessed; + + const allErrors = [ + ...productCurrentPricesResult.errors, + ...dailyInventoryResult.errors, + ...productStatHistoryResult.errors + ]; + + // Log import summary + console.log(` +Historical data import complete: +------------------------------- +Records added: ${totalRecordsAdded} +Records updated: ${totalRecordsUpdated} +Total processed: ${totalProcessed} +Errors: ${allErrors.length} +Time taken: ${formatElapsedTime(startTime)} + `); + + // Final progress update + outputProgress({ + status: "complete", + operation: "Historical data import", + message: `Import complete. Added: ${totalRecordsAdded}, Updated: ${totalRecordsUpdated}, Errors: ${allErrors.length}`, + current: tablesCount, + total: tablesCount, + elapsed: formatElapsedTime(startTime) + }); + + // Log any errors + if (allErrors.length > 0) { + console.log('Errors encountered during import:'); + console.log(JSON.stringify(allErrors, null, 2)); + } + + // Calculate duration + const endTime = Date.now(); + const durationSeconds = Math.round((endTime - startTime) / 1000); + const finalStatus = allErrors.length === 0 ? 'complete' : 'failed'; + const errorMessage = allErrors.length > 0 ? JSON.stringify(allErrors) : null; + + // Update import history + await localConnection.query(` + INSERT INTO import_history ( + table_name, + end_time, + duration_seconds, + records_added, + records_updated, + is_incremental, + status, + error_message, + additional_info + ) + VALUES ($1, NOW(), $2, $3, $4, $5, $6, $7, $8) + `, [ + 'historical_data_combined', + durationSeconds, + totalRecordsAdded, + totalRecordsUpdated, + incrementalUpdate, + finalStatus, + errorMessage, + JSON.stringify({ + totalProcessed, + tablesImported: { + imported_product_current_prices: IMPORT_PRODUCT_CURRENT_PRICES, + imported_daily_inventory: IMPORT_DAILY_INVENTORY, + imported_product_stat_history: IMPORT_PRODUCT_STAT_HISTORY + } + }) + ]); + + // Return summary + return { + recordsAdded: totalRecordsAdded, + recordsUpdated: totalRecordsUpdated, + totalProcessed, + errors: allErrors, + timeTaken: formatElapsedTime(startTime) + }; + } catch (error) { + console.error('Error importing historical data:', error); + + // Final progress update on error + outputProgress({ + status: "failed", + operation: "Historical data import", + message: `Import failed: ${error.message}`, + elapsed: formatElapsedTime(startTime) + }); + + throw error; + } +} + +/** + * Imports product_current_prices data from MySQL to PostgreSQL + */ +async function importProductCurrentPrices( + prodConnection, + localConnection, + oneYearAgoStr, + lastSyncTime, + batchSize, + insertBatchSize, + incrementalUpdate, + startTime +) { + let recordsAdded = 0; + let recordsUpdated = 0; + let totalProcessed = 0; + let errors = []; + let offset = 0; + let allProcessed = false; + + try { + // Get total count for progress reporting + const [countResult] = await prodConnection.query(` + SELECT COUNT(*) as total + FROM product_current_prices + WHERE (date_active >= ? OR date_deactive >= ?) + ${incrementalUpdate && lastSyncTime ? `AND date_deactive > ?` : ''} + `, [oneYearAgoStr, oneYearAgoStr, ...(incrementalUpdate && lastSyncTime ? [lastSyncTime] : [])]); + + const totalCount = countResult[0].total; + + outputProgress({ + status: "running", + operation: "Historical data import - Product Current Prices", + message: `Found ${totalCount} records to process`, + current: 0, + total: totalCount, + elapsed: formatElapsedTime(startTime) + }); + + // Process in batches for better performance + while (!allProcessed) { + try { + // Fetch batch from production + const [rows] = await prodConnection.query(` + SELECT + price_id, + pid, + qty_buy, + is_min_qty_buy, + price_each, + qty_limit, + no_promo, + checkout_offer, + active, + date_active, + date_deactive + FROM product_current_prices + WHERE (date_active >= ? OR date_deactive >= ?) + ${incrementalUpdate && lastSyncTime ? `AND date_deactive > ?` : ''} + ORDER BY price_id + LIMIT ? OFFSET ? + `, [ + oneYearAgoStr, + oneYearAgoStr, + ...(incrementalUpdate && lastSyncTime ? [lastSyncTime] : []), + batchSize, + offset + ]); + + if (rows.length === 0) { + allProcessed = true; + break; + } + + // Process rows in smaller batches for better performance + for (let i = 0; i < rows.length; i += insertBatchSize) { + const batch = rows.slice(i, i + insertBatchSize); + + if (batch.length === 0) continue; + + try { + // Build parameterized query to handle NULL values properly + const values = []; + const placeholders = []; + let placeholderIndex = 1; + + for (const row of batch) { + const rowPlaceholders = [ + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}` + ]; + + placeholders.push(`(${rowPlaceholders.join(', ')})`); + + values.push( + row.price_id, + row.pid, + row.qty_buy, + row.is_min_qty_buy ? true : false, + row.price_each, + row.qty_limit, // PostgreSQL will handle null values properly + row.no_promo ? true : false, + row.checkout_offer ? true : false, + row.active ? true : false, + validateDate(row.date_active), + validateDate(row.date_deactive) + ); + } + + // Execute batch insert + const result = await localConnection.query(` + WITH ins AS ( + INSERT INTO imported_product_current_prices ( + price_id, pid, qty_buy, is_min_qty_buy, price_each, qty_limit, + no_promo, checkout_offer, active, date_active, date_deactive + ) + VALUES ${placeholders.join(',\n')} + ON CONFLICT (price_id) DO UPDATE SET + pid = EXCLUDED.pid, + qty_buy = EXCLUDED.qty_buy, + is_min_qty_buy = EXCLUDED.is_min_qty_buy, + price_each = EXCLUDED.price_each, + qty_limit = EXCLUDED.qty_limit, + no_promo = EXCLUDED.no_promo, + checkout_offer = EXCLUDED.checkout_offer, + active = EXCLUDED.active, + date_active = EXCLUDED.date_active, + date_deactive = EXCLUDED.date_deactive, + updated = CURRENT_TIMESTAMP + RETURNING (xmax = 0) AS inserted + ) + SELECT + COUNT(*) FILTER (WHERE inserted) AS inserted_count, + COUNT(*) FILTER (WHERE NOT inserted) AS updated_count + FROM ins + `, values); + + // Safely update counts based on the result + if (result && result.rows && result.rows.length > 0) { + const insertedCount = parseInt(result.rows[0].inserted_count || 0); + const updatedCount = parseInt(result.rows[0].updated_count || 0); + + recordsAdded += insertedCount; + recordsUpdated += updatedCount; + } + } catch (error) { + console.error(`Error in batch import of product_current_prices at offset ${i}:`, error); + errors.push({ + table: 'imported_product_current_prices', + batchOffset: i, + batchSize: batch.length, + error: error.message + }); + } + } + + totalProcessed += rows.length; + offset += rows.length; + + // Update progress + outputProgress({ + status: "running", + operation: "Historical data import - Product Current Prices", + message: `Processed ${totalProcessed} of ${totalCount} records`, + current: totalProcessed, + total: totalCount, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, totalProcessed, totalCount), + rate: calculateRate(startTime, totalProcessed) + }); + } catch (error) { + console.error('Error in batch import of product_current_prices:', error); + errors.push({ + table: 'imported_product_current_prices', + error: error.message, + offset: offset, + batchSize: batchSize + }); + + // Try to continue with next batch + offset += batchSize; + } + } + + // Update sync status + await localConnection.query(` + INSERT INTO sync_status (table_name, last_sync_timestamp) + VALUES ('imported_product_current_prices', NOW()) + ON CONFLICT (table_name) DO UPDATE SET + last_sync_timestamp = NOW() + `); + + return { recordsAdded, recordsUpdated, totalProcessed, errors }; + } catch (error) { + console.error('Error in product current prices import:', error); + return { + recordsAdded, + recordsUpdated, + totalProcessed, + errors: [...errors, { + table: 'imported_product_current_prices', + error: error.message + }] + }; + } +} + +/** + * Imports daily_inventory data from MySQL to PostgreSQL + */ +async function importDailyInventory( + prodConnection, + localConnection, + oneYearAgoStr, + lastSyncTime, + batchSize, + insertBatchSize, + incrementalUpdate, + startTime +) { + let recordsAdded = 0; + let recordsUpdated = 0; + let totalProcessed = 0; + let errors = []; + let offset = 0; + let allProcessed = false; + + try { + // Get total count for progress reporting + const [countResult] = await prodConnection.query(` + SELECT COUNT(*) as total + FROM daily_inventory + WHERE date >= ? + ${incrementalUpdate && lastSyncTime ? `AND stamp > ?` : ''} + `, [oneYearAgoStr, ...(incrementalUpdate && lastSyncTime ? [lastSyncTime] : [])]); + + const totalCount = countResult[0].total; + + outputProgress({ + status: "running", + operation: "Historical data import - Daily Inventory", + message: `Found ${totalCount} records to process`, + current: 0, + total: totalCount, + elapsed: formatElapsedTime(startTime) + }); + + // Process in batches for better performance + while (!allProcessed) { + try { + // Fetch batch from production + const [rows] = await prodConnection.query(` + SELECT + date, + pid, + amountsold, + times_sold, + qtyreceived, + price, + costeach, + stamp + FROM daily_inventory + WHERE date >= ? + ${incrementalUpdate && lastSyncTime ? `AND stamp > ?` : ''} + ORDER BY date, pid + LIMIT ? OFFSET ? + `, [ + oneYearAgoStr, + ...(incrementalUpdate && lastSyncTime ? [lastSyncTime] : []), + batchSize, + offset + ]); + + if (rows.length === 0) { + allProcessed = true; + break; + } + + // Process rows in smaller batches for better performance + for (let i = 0; i < rows.length; i += insertBatchSize) { + const batch = rows.slice(i, i + insertBatchSize); + + if (batch.length === 0) continue; + + try { + // Build parameterized query to handle NULL values properly + const values = []; + const placeholders = []; + let placeholderIndex = 1; + + for (const row of batch) { + const rowPlaceholders = [ + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}` + ]; + + placeholders.push(`(${rowPlaceholders.join(', ')})`); + + values.push( + validateDate(row.date), + row.pid, + row.amountsold || 0, + row.times_sold || 0, + row.qtyreceived || 0, + row.price || 0, + row.costeach || 0, + validateDate(row.stamp) + ); + } + + // Execute batch insert + const result = await localConnection.query(` + WITH ins AS ( + INSERT INTO imported_daily_inventory ( + date, pid, amountsold, times_sold, qtyreceived, price, costeach, stamp + ) + VALUES ${placeholders.join(',\n')} + ON CONFLICT (date, pid) DO UPDATE SET + amountsold = EXCLUDED.amountsold, + times_sold = EXCLUDED.times_sold, + qtyreceived = EXCLUDED.qtyreceived, + price = EXCLUDED.price, + costeach = EXCLUDED.costeach, + stamp = EXCLUDED.stamp, + updated = CURRENT_TIMESTAMP + RETURNING (xmax = 0) AS inserted + ) + SELECT + COUNT(*) FILTER (WHERE inserted) AS inserted_count, + COUNT(*) FILTER (WHERE NOT inserted) AS updated_count + FROM ins + `, values); + + // Safely update counts based on the result + if (result && result.rows && result.rows.length > 0) { + const insertedCount = parseInt(result.rows[0].inserted_count || 0); + const updatedCount = parseInt(result.rows[0].updated_count || 0); + + recordsAdded += insertedCount; + recordsUpdated += updatedCount; + } + } catch (error) { + console.error(`Error in batch import of daily_inventory at offset ${i}:`, error); + errors.push({ + table: 'imported_daily_inventory', + batchOffset: i, + batchSize: batch.length, + error: error.message + }); + } + } + + totalProcessed += rows.length; + offset += rows.length; + + // Update progress + outputProgress({ + status: "running", + operation: "Historical data import - Daily Inventory", + message: `Processed ${totalProcessed} of ${totalCount} records`, + current: totalProcessed, + total: totalCount, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, totalProcessed, totalCount), + rate: calculateRate(startTime, totalProcessed) + }); + } catch (error) { + console.error('Error in batch import of daily_inventory:', error); + errors.push({ + table: 'imported_daily_inventory', + error: error.message, + offset: offset, + batchSize: batchSize + }); + + // Try to continue with next batch + offset += batchSize; + } + } + + // Update sync status + await localConnection.query(` + INSERT INTO sync_status (table_name, last_sync_timestamp) + VALUES ('imported_daily_inventory', NOW()) + ON CONFLICT (table_name) DO UPDATE SET + last_sync_timestamp = NOW() + `); + + return { recordsAdded, recordsUpdated, totalProcessed, errors }; + } catch (error) { + console.error('Error in daily inventory import:', error); + return { + recordsAdded, + recordsUpdated, + totalProcessed, + errors: [...errors, { + table: 'imported_daily_inventory', + error: error.message + }] + }; + } +} + +/** + * Imports product_stat_history data from MySQL to PostgreSQL + * Using fast direct inserts without conflict checking + */ +async function importProductStatHistory( + prodConnection, + localConnection, + recentDateStr, // Use more recent date instead of one year ago + lastSyncTime, + batchSize, + insertBatchSize, + incrementalUpdate, + startTime, + recentMonths // Add parameter for recent months +) { + let recordsAdded = 0; + let recordsUpdated = 0; + let totalProcessed = 0; + let errors = []; + let offset = 0; + let allProcessed = false; + let lastRateCheck = Date.now(); + let lastProcessed = 0; + + try { + // Get total count for progress reporting + const [countResult] = await prodConnection.query(` + SELECT COUNT(*) as total + FROM product_stat_history + WHERE date >= ? + ${incrementalUpdate && lastSyncTime ? `AND date > ?` : ''} + `, [recentDateStr, ...(incrementalUpdate && lastSyncTime ? [lastSyncTime] : [])]); + + const totalCount = countResult[0].total; + console.log(`Found ${totalCount} records to process in product_stat_history (using recent date: ${recentDateStr})`); + + // Progress indicator + outputProgress({ + status: "running", + operation: "Historical data import - Product Stat History", + message: `Found ${totalCount} records to process (last ${recentMonths} months only)`, + current: 0, + total: totalCount, + elapsed: formatElapsedTime(startTime) + }); + + // If not incremental, truncate the table first for better performance + if (!incrementalUpdate) { + console.log('Truncating imported_product_stat_history for full import...'); + await localConnection.query('TRUNCATE TABLE imported_product_stat_history'); + } else if (lastSyncTime) { + // For incremental updates, delete records that will be reimported + console.log(`Deleting records from imported_product_stat_history since ${lastSyncTime}...`); + await localConnection.query('DELETE FROM imported_product_stat_history WHERE date > $1', [lastSyncTime]); + } + + // Process in batches for better performance + while (!allProcessed) { + try { + // Fetch batch from production with minimal filtering and no sorting + const [rows] = await prodConnection.query(` + SELECT + pid, + date, + COALESCE(score, 0) as score, + COALESCE(score2, 0) as score2, + COALESCE(qty_in_baskets, 0) as qty_in_baskets, + COALESCE(qty_sold, 0) as qty_sold, + COALESCE(notifies_set, 0) as notifies_set, + COALESCE(visibility_score, 0) as visibility_score, + COALESCE(health_score, 0) as health_score, + COALESCE(sold_view_score, 0) as sold_view_score + FROM product_stat_history + WHERE date >= ? + ${incrementalUpdate && lastSyncTime ? `AND date > ?` : ''} + LIMIT ? OFFSET ? + `, [ + recentDateStr, + ...(incrementalUpdate && lastSyncTime ? [lastSyncTime] : []), + batchSize, + offset + ]); + + if (rows.length === 0) { + allProcessed = true; + break; + } + + // Process rows in smaller batches for better performance + for (let i = 0; i < rows.length; i += insertBatchSize) { + const batch = rows.slice(i, i + insertBatchSize); + + if (batch.length === 0) continue; + + try { + // Build parameterized query to handle NULL values properly + const values = []; + const placeholders = []; + let placeholderIndex = 1; + + for (const row of batch) { + const rowPlaceholders = [ + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}`, + `$${placeholderIndex++}` + ]; + + placeholders.push(`(${rowPlaceholders.join(', ')})`); + + values.push( + row.pid, + validateDate(row.date), + row.score, + row.score2, + row.qty_in_baskets, + row.qty_sold, + row.notifies_set, + row.visibility_score, + row.health_score, + row.sold_view_score + ); + } + + // Execute direct batch insert without conflict checking + await localConnection.query(` + INSERT INTO imported_product_stat_history ( + pid, date, score, score2, qty_in_baskets, qty_sold, notifies_set, + visibility_score, health_score, sold_view_score + ) + VALUES ${placeholders.join(',\n')} + `, values); + + // All inserts are new records when using this approach + recordsAdded += batch.length; + } catch (error) { + console.error(`Error in batch insert of product_stat_history at offset ${i}:`, error); + errors.push({ + table: 'imported_product_stat_history', + batchOffset: i, + batchSize: batch.length, + error: error.message + }); + } + } + + totalProcessed += rows.length; + offset += rows.length; + + // Calculate current rate every 10 seconds or 100,000 records + const now = Date.now(); + if (now - lastRateCheck > 10000 || totalProcessed - lastProcessed > 100000) { + const timeElapsed = (now - lastRateCheck) / 1000; // seconds + const recordsProcessed = totalProcessed - lastProcessed; + const currentRate = Math.round(recordsProcessed / timeElapsed); + + console.log(`Current import rate: ${currentRate} records/second`); + + lastRateCheck = now; + lastProcessed = totalProcessed; + } + + // Update progress + outputProgress({ + status: "running", + operation: "Historical data import - Product Stat History", + message: `Processed ${totalProcessed} of ${totalCount} records`, + current: totalProcessed, + total: totalCount, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, totalProcessed, totalCount), + rate: calculateRate(startTime, totalProcessed) + }); + } catch (error) { + console.error('Error in batch import of product_stat_history:', error); + errors.push({ + table: 'imported_product_stat_history', + error: error.message, + offset: offset, + batchSize: batchSize + }); + + // Try to continue with next batch + offset += batchSize; + } + } + + // Update sync status + await localConnection.query(` + INSERT INTO sync_status (table_name, last_sync_timestamp) + VALUES ('imported_product_stat_history', NOW()) + ON CONFLICT (table_name) DO UPDATE SET + last_sync_timestamp = NOW() + `); + + return { recordsAdded, recordsUpdated, totalProcessed, errors }; + } catch (error) { + console.error('Error in product stat history import:', error); + return { + recordsAdded, + recordsUpdated, + totalProcessed, + errors: [...errors, { + table: 'imported_product_stat_history', + error: error.message + }] + }; + } +} + +module.exports = importHistoricalData; \ No newline at end of file diff --git a/inventory-server/old/metrics-schema.sql b/inventory-server/old/metrics-schema.sql new file mode 100644 index 0000000..99f791f --- /dev/null +++ b/inventory-server/old/metrics-schema.sql @@ -0,0 +1,377 @@ +-- Disable foreign key checks +SET session_replication_role = 'replica'; + +-- Temporary tables for batch metrics processing +CREATE TABLE temp_sales_metrics ( + pid BIGINT NOT NULL, + daily_sales_avg DECIMAL(10,3), + weekly_sales_avg DECIMAL(10,3), + monthly_sales_avg DECIMAL(10,3), + total_revenue DECIMAL(10,3), + avg_margin_percent DECIMAL(10,3), + first_sale_date DATE, + last_sale_date DATE, + stddev_daily_sales DECIMAL(10,3), + PRIMARY KEY (pid) +); + +CREATE TABLE temp_purchase_metrics ( + pid BIGINT NOT NULL, + avg_lead_time_days DECIMAL(10,2), + last_purchase_date DATE, + first_received_date DATE, + last_received_date DATE, + stddev_lead_time_days DECIMAL(10,2), + PRIMARY KEY (pid) +); + +-- New table for product metrics +CREATE TABLE product_metrics ( + pid BIGINT NOT NULL, + last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Sales velocity metrics + daily_sales_avg DECIMAL(10,3), + weekly_sales_avg DECIMAL(10,3), + monthly_sales_avg DECIMAL(10,3), + avg_quantity_per_order DECIMAL(10,3), + number_of_orders INTEGER, + first_sale_date DATE, + last_sale_date DATE, + -- Stock metrics + days_of_inventory INTEGER, + weeks_of_inventory INTEGER, + reorder_point INTEGER, + safety_stock INTEGER, + reorder_qty INTEGER DEFAULT 0, + overstocked_amt INTEGER DEFAULT 0, + -- Financial metrics + avg_margin_percent DECIMAL(10,3), + total_revenue DECIMAL(10,3), + inventory_value DECIMAL(10,3), + cost_of_goods_sold DECIMAL(10,3), + gross_profit DECIMAL(10,3), + gmroi DECIMAL(10,3), + -- Purchase metrics + avg_lead_time_days DECIMAL(10,2), + last_purchase_date DATE, + first_received_date DATE, + last_received_date DATE, + -- Classification metrics + abc_class CHAR(1), + stock_status VARCHAR(20), + -- Turnover metrics + turnover_rate DECIMAL(12,3), + -- Lead time metrics + current_lead_time INTEGER, + target_lead_time INTEGER, + lead_time_status VARCHAR(20), + -- Forecast metrics + forecast_accuracy DECIMAL(5,2) DEFAULT NULL, + forecast_bias DECIMAL(5,2) DEFAULT NULL, + last_forecast_date DATE DEFAULT NULL, + PRIMARY KEY (pid), + FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE +); + +CREATE INDEX idx_metrics_revenue ON product_metrics(total_revenue); +CREATE INDEX idx_metrics_stock_status ON product_metrics(stock_status); +CREATE INDEX idx_metrics_lead_time ON product_metrics(lead_time_status); +CREATE INDEX idx_metrics_turnover ON product_metrics(turnover_rate); +CREATE INDEX idx_metrics_last_calculated ON product_metrics(last_calculated_at); +CREATE INDEX idx_metrics_abc ON product_metrics(abc_class); +CREATE INDEX idx_metrics_sales ON product_metrics(daily_sales_avg, weekly_sales_avg, monthly_sales_avg); +CREATE INDEX idx_metrics_forecast ON product_metrics(forecast_accuracy, forecast_bias); + +-- New table for time-based aggregates +CREATE TABLE product_time_aggregates ( + pid BIGINT NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + -- Sales metrics + total_quantity_sold INTEGER DEFAULT 0, + total_revenue DECIMAL(10,3) DEFAULT 0, + total_cost DECIMAL(10,3) DEFAULT 0, + order_count INTEGER DEFAULT 0, + -- Stock changes + stock_received INTEGER DEFAULT 0, + stock_ordered INTEGER DEFAULT 0, + -- Calculated fields + avg_price DECIMAL(10,3), + profit_margin DECIMAL(10,3), + inventory_value DECIMAL(10,3), + gmroi DECIMAL(10,3), + PRIMARY KEY (pid, year, month), + FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE +); + +CREATE INDEX idx_date ON product_time_aggregates(year, month); + +-- Create vendor_details table +CREATE TABLE vendor_details ( + vendor VARCHAR(100) PRIMARY KEY, + contact_name VARCHAR(100), + email VARCHAR(255), + phone VARCHAR(50), + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_vendor_details_status ON vendor_details(status); + +-- New table for vendor metrics +CREATE TABLE vendor_metrics ( + vendor VARCHAR(100) NOT NULL, + last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Performance metrics + avg_lead_time_days DECIMAL(10,3), + on_time_delivery_rate DECIMAL(5,2), + order_fill_rate DECIMAL(5,2), + total_orders INTEGER DEFAULT 0, + total_late_orders INTEGER DEFAULT 0, + total_purchase_value DECIMAL(10,3) DEFAULT 0, + avg_order_value DECIMAL(10,3), + -- Product metrics + active_products INTEGER DEFAULT 0, + total_products INTEGER DEFAULT 0, + -- Financial metrics + total_revenue DECIMAL(10,3) DEFAULT 0, + avg_margin_percent DECIMAL(5,2), + -- Status + status VARCHAR(20) DEFAULT 'active', + PRIMARY KEY (vendor), + FOREIGN KEY (vendor) REFERENCES vendor_details(vendor) ON DELETE CASCADE +); + +CREATE INDEX idx_vendor_performance ON vendor_metrics(on_time_delivery_rate); +CREATE INDEX idx_vendor_status ON vendor_metrics(status); +CREATE INDEX idx_vendor_metrics_last_calculated ON vendor_metrics(last_calculated_at); +CREATE INDEX idx_vendor_metrics_orders ON vendor_metrics(total_orders, total_late_orders); + +-- New table for category metrics +CREATE TABLE category_metrics ( + category_id BIGINT NOT NULL, + last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Product metrics + product_count INTEGER DEFAULT 0, + active_products INTEGER DEFAULT 0, + -- Financial metrics + total_value DECIMAL(15,3) DEFAULT 0, + avg_margin DECIMAL(5,2), + turnover_rate DECIMAL(12,3), + growth_rate DECIMAL(5,2), + -- Status + status VARCHAR(20) DEFAULT 'active', + PRIMARY KEY (category_id), + FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE +); + +CREATE INDEX idx_category_status ON category_metrics(status); +CREATE INDEX idx_category_growth ON category_metrics(growth_rate); +CREATE INDEX idx_metrics_last_calculated_cat ON category_metrics(last_calculated_at); +CREATE INDEX idx_category_metrics_products ON category_metrics(product_count, active_products); + +-- New table for vendor time-based metrics +CREATE TABLE vendor_time_metrics ( + vendor VARCHAR(100) NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + -- Order metrics + total_orders INTEGER DEFAULT 0, + late_orders INTEGER DEFAULT 0, + avg_lead_time_days DECIMAL(10,3), + -- Financial metrics + total_purchase_value DECIMAL(10,3) DEFAULT 0, + total_revenue DECIMAL(10,3) DEFAULT 0, + avg_margin_percent DECIMAL(5,2), + PRIMARY KEY (vendor, year, month), + FOREIGN KEY (vendor) REFERENCES vendor_details(vendor) ON DELETE CASCADE +); + +CREATE INDEX idx_vendor_date ON vendor_time_metrics(year, month); + +-- New table for category time-based metrics +CREATE TABLE category_time_metrics ( + category_id BIGINT NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + -- Product metrics + product_count INTEGER DEFAULT 0, + active_products INTEGER DEFAULT 0, + -- Financial metrics + total_value DECIMAL(15,3) DEFAULT 0, + total_revenue DECIMAL(15,3) DEFAULT 0, + avg_margin DECIMAL(5,2), + turnover_rate DECIMAL(12,3), + PRIMARY KEY (category_id, year, month), + FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE +); + +CREATE INDEX idx_category_date ON category_time_metrics(year, month); + +-- New table for category-based sales metrics +CREATE TABLE category_sales_metrics ( + category_id BIGINT NOT NULL, + brand VARCHAR(100) NOT NULL, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + avg_daily_sales DECIMAL(10,3) DEFAULT 0, + total_sold INTEGER DEFAULT 0, + num_products INTEGER DEFAULT 0, + avg_price DECIMAL(10,3) DEFAULT 0, + last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (category_id, brand, period_start, period_end), + FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE +); + +CREATE INDEX idx_category_brand ON category_sales_metrics(category_id, brand); +CREATE INDEX idx_period ON category_sales_metrics(period_start, period_end); + +-- New table for brand metrics +CREATE TABLE brand_metrics ( + brand VARCHAR(100) NOT NULL, + last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Product metrics + product_count INTEGER DEFAULT 0, + active_products INTEGER DEFAULT 0, + -- Stock metrics + total_stock_units INTEGER DEFAULT 0, + total_stock_cost DECIMAL(15,2) DEFAULT 0, + total_stock_retail DECIMAL(15,2) DEFAULT 0, + -- Sales metrics + total_revenue DECIMAL(15,2) DEFAULT 0, + avg_margin DECIMAL(5,2) DEFAULT 0, + growth_rate DECIMAL(5,2) DEFAULT 0, + PRIMARY KEY (brand) +); + +CREATE INDEX idx_brand_metrics_last_calculated ON brand_metrics(last_calculated_at); +CREATE INDEX idx_brand_metrics_revenue ON brand_metrics(total_revenue); +CREATE INDEX idx_brand_metrics_growth ON brand_metrics(growth_rate); + +-- New table for brand time-based metrics +CREATE TABLE brand_time_metrics ( + brand VARCHAR(100) NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + -- Product metrics + product_count INTEGER DEFAULT 0, + active_products INTEGER DEFAULT 0, + -- Stock metrics + total_stock_units INTEGER DEFAULT 0, + total_stock_cost DECIMAL(15,2) DEFAULT 0, + total_stock_retail DECIMAL(15,2) DEFAULT 0, + -- Sales metrics + total_revenue DECIMAL(15,2) DEFAULT 0, + avg_margin DECIMAL(5,2) DEFAULT 0, + growth_rate DECIMAL(5,2) DEFAULT 0, + PRIMARY KEY (brand, year, month) +); + +CREATE INDEX idx_brand_time_date ON brand_time_metrics(year, month); + +-- New table for sales forecasts +CREATE TABLE sales_forecasts ( + pid BIGINT NOT NULL, + forecast_date DATE NOT NULL, + forecast_quantity INTEGER, + confidence_level DECIMAL(5,2), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (pid, forecast_date), + FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE +); + +CREATE INDEX idx_forecast_date ON sales_forecasts(forecast_date); + +-- New table for category forecasts +CREATE TABLE category_forecasts ( + category_id BIGINT NOT NULL, + forecast_date DATE NOT NULL, + forecast_revenue DECIMAL(15,2), + forecast_units INTEGER, + confidence_level DECIMAL(5,2), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (category_id, forecast_date), + FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE +); + +CREATE INDEX idx_cat_forecast_date ON category_forecasts(forecast_date); + +-- Create views for common calculations +CREATE OR REPLACE VIEW inventory_health AS +WITH stock_levels AS ( + SELECT + p.pid, + p.title, + p.SKU, + p.stock_quantity, + p.preorder_count, + pm.daily_sales_avg, + pm.weekly_sales_avg, + pm.monthly_sales_avg, + pm.reorder_point, + pm.safety_stock, + pm.days_of_inventory, + pm.weeks_of_inventory, + pm.stock_status, + pm.abc_class, + pm.turnover_rate, + pm.avg_lead_time_days, + pm.current_lead_time, + pm.target_lead_time, + pm.lead_time_status, + p.cost_price, + p.price, + pm.inventory_value, + pm.gmroi + FROM products p + LEFT JOIN product_metrics pm ON p.pid = pm.pid + WHERE p.managing_stock = true AND p.visible = true +) +SELECT + *, + CASE + WHEN stock_quantity <= safety_stock THEN 'Critical' + WHEN stock_quantity <= reorder_point THEN 'Low' + WHEN stock_quantity > (reorder_point * 3) THEN 'Excess' + ELSE 'Healthy' + END as inventory_status, + CASE + WHEN lead_time_status = 'delayed' AND stock_status = 'low' THEN 'High' + WHEN lead_time_status = 'delayed' OR stock_status = 'low' THEN 'Medium' + ELSE 'Low' + END as risk_level +FROM stock_levels; + +-- Create view for category performance trends +CREATE OR REPLACE VIEW category_performance_trends AS +WITH monthly_trends AS ( + SELECT + c.cat_id, + c.name as category_name, + ctm.year, + ctm.month, + ctm.product_count, + ctm.active_products, + ctm.total_value, + ctm.total_revenue, + ctm.avg_margin, + ctm.turnover_rate, + LAG(ctm.total_revenue) OVER (PARTITION BY c.cat_id ORDER BY ctm.year, ctm.month) as prev_month_revenue, + LAG(ctm.turnover_rate) OVER (PARTITION BY c.cat_id ORDER BY ctm.year, ctm.month) as prev_month_turnover + FROM categories c + JOIN category_time_metrics ctm ON c.cat_id = ctm.category_id +) +SELECT + *, + CASE + WHEN prev_month_revenue IS NULL THEN 0 + ELSE ((total_revenue - prev_month_revenue) / prev_month_revenue) * 100 + END as revenue_growth_percent, + CASE + WHEN prev_month_turnover IS NULL THEN 0 + ELSE ((turnover_rate - prev_month_turnover) / prev_month_turnover) * 100 + END as turnover_growth_percent +FROM monthly_trends; + +SET session_replication_role = 'origin'; \ No newline at end of file diff --git a/inventory-server/old/metrics/brand-metrics.js b/inventory-server/old/metrics/brand-metrics.js new file mode 100644 index 0000000..8e7c4dc --- /dev/null +++ b/inventory-server/old/metrics/brand-metrics.js @@ -0,0 +1,321 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); +const { getConnection } = require('./utils/db'); + +async function calculateBrandMetrics(startTime, totalProducts, processedCount = 0, isCancelled = false) { + const connection = await getConnection(); + let success = false; + let processedOrders = 0; + + try { + if (isCancelled) { + outputProgress({ + status: 'cancelled', + operation: 'Brand metrics calculation cancelled', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: null, + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + return { + processedProducts: processedCount, + processedOrders: 0, + processedPurchaseOrders: 0, + success + }; + } + + // Get order count that will be processed + const orderCount = await connection.query(` + SELECT COUNT(*) as count + FROM orders o + WHERE o.canceled = false + `); + processedOrders = parseInt(orderCount.rows[0].count); + + outputProgress({ + status: 'running', + operation: 'Starting brand metrics calculation', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Calculate brand metrics with optimized queries + await connection.query(` + INSERT INTO brand_metrics ( + brand, + product_count, + active_products, + total_stock_units, + total_stock_cost, + total_stock_retail, + total_revenue, + avg_margin, + growth_rate + ) + WITH filtered_products AS ( + SELECT + p.*, + CASE + WHEN p.stock_quantity <= 5000 AND p.stock_quantity >= 0 + THEN p.pid + END as valid_pid, + CASE + WHEN p.visible = true + AND p.stock_quantity <= 5000 + AND p.stock_quantity >= 0 + THEN p.pid + END as active_pid, + CASE + WHEN p.stock_quantity IS NULL + OR p.stock_quantity < 0 + OR p.stock_quantity > 5000 + THEN 0 + ELSE p.stock_quantity + END as valid_stock + FROM products p + WHERE p.brand IS NOT NULL + ), + sales_periods AS ( + SELECT + p.brand, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0))) as period_revenue, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0) - p.cost_price)) as period_margin, + COUNT(DISTINCT DATE(o.date)) as period_days, + CASE + WHEN o.date >= CURRENT_DATE - INTERVAL '3 months' THEN 'current' + WHEN o.date BETWEEN CURRENT_DATE - INTERVAL '15 months' + AND CURRENT_DATE - INTERVAL '12 months' THEN 'previous' + END as period_type + FROM filtered_products p + JOIN orders o ON p.pid = o.pid + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '15 months' + GROUP BY p.brand, period_type + ), + brand_data AS ( + SELECT + p.brand, + COUNT(DISTINCT p.valid_pid) as product_count, + COUNT(DISTINCT p.active_pid) as active_products, + SUM(p.valid_stock) as total_stock_units, + SUM(p.valid_stock * p.cost_price) as total_stock_cost, + SUM(p.valid_stock * p.price) as total_stock_retail, + COALESCE(SUM(o.quantity * (o.price - COALESCE(o.discount, 0))), 0) as total_revenue, + CASE + WHEN SUM(o.quantity * o.price) > 0 + THEN GREATEST( + -100.0, + LEAST( + 100.0, + ( + SUM(o.quantity * o.price) - -- Use gross revenue (before discounts) + SUM(o.quantity * COALESCE(p.cost_price, 0)) -- Total costs + ) * 100.0 / + NULLIF(SUM(o.quantity * o.price), 0) -- Divide by gross revenue + ) + ) + ELSE 0 + END as avg_margin + FROM filtered_products p + LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false + GROUP BY p.brand + ) + SELECT + bd.brand, + bd.product_count, + bd.active_products, + bd.total_stock_units, + bd.total_stock_cost, + bd.total_stock_retail, + bd.total_revenue, + bd.avg_margin, + CASE + WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0 + AND MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) > 0 + THEN 100.0 + WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0 + THEN 0.0 + ELSE GREATEST( + -100.0, + LEAST( + ((MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) - + MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END)) / + NULLIF(ABS(MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END)), 0)) * 100.0, + 999.99 + ) + ) + END as growth_rate + FROM brand_data bd + LEFT JOIN sales_periods sp ON bd.brand = sp.brand + GROUP BY bd.brand, bd.product_count, bd.active_products, bd.total_stock_units, + bd.total_stock_cost, bd.total_stock_retail, bd.total_revenue, bd.avg_margin + ON CONFLICT (brand) DO UPDATE + SET + product_count = EXCLUDED.product_count, + active_products = EXCLUDED.active_products, + total_stock_units = EXCLUDED.total_stock_units, + total_stock_cost = EXCLUDED.total_stock_cost, + total_stock_retail = EXCLUDED.total_stock_retail, + total_revenue = EXCLUDED.total_revenue, + avg_margin = EXCLUDED.avg_margin, + growth_rate = EXCLUDED.growth_rate, + last_calculated_at = CURRENT_TIMESTAMP + `); + + processedCount = Math.floor(totalProducts * 0.97); + outputProgress({ + status: 'running', + operation: 'Brand metrics calculated, starting time-based metrics', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Calculate brand time-based metrics with optimized query + await connection.query(` + INSERT INTO brand_time_metrics ( + brand, + year, + month, + product_count, + active_products, + total_stock_units, + total_stock_cost, + total_stock_retail, + total_revenue, + avg_margin + ) + WITH filtered_products AS ( + SELECT + p.*, + CASE WHEN p.stock_quantity <= 5000 THEN p.pid END as valid_pid, + CASE WHEN p.visible = true AND p.stock_quantity <= 5000 THEN p.pid END as active_pid, + CASE + WHEN p.stock_quantity IS NULL OR p.stock_quantity < 0 OR p.stock_quantity > 5000 THEN 0 + ELSE p.stock_quantity + END as valid_stock + FROM products p + WHERE p.brand IS NOT NULL + ), + monthly_metrics AS ( + SELECT + p.brand, + EXTRACT(YEAR FROM o.date::timestamp with time zone) as year, + EXTRACT(MONTH FROM o.date::timestamp with time zone) as month, + COUNT(DISTINCT p.valid_pid) as product_count, + COUNT(DISTINCT p.active_pid) as active_products, + SUM(p.valid_stock) as total_stock_units, + SUM(p.valid_stock * p.cost_price) as total_stock_cost, + SUM(p.valid_stock * p.price) as total_stock_retail, + SUM(o.quantity * o.price) as total_revenue, + CASE + WHEN SUM(o.quantity * o.price) > 0 + THEN GREATEST( + -100.0, + LEAST( + 100.0, + ( + SUM(o.quantity * o.price) - -- Use gross revenue (before discounts) + SUM(o.quantity * COALESCE(p.cost_price, 0)) -- Total costs + ) * 100.0 / + NULLIF(SUM(o.quantity * o.price), 0) -- Divide by gross revenue + ) + ) + ELSE 0 + END as avg_margin + FROM filtered_products p + LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false + WHERE o.date >= CURRENT_DATE - INTERVAL '12 months' + GROUP BY p.brand, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone) + ) + SELECT * + FROM monthly_metrics + ON CONFLICT (brand, year, month) DO UPDATE + SET + product_count = EXCLUDED.product_count, + active_products = EXCLUDED.active_products, + total_stock_units = EXCLUDED.total_stock_units, + total_stock_cost = EXCLUDED.total_stock_cost, + total_stock_retail = EXCLUDED.total_stock_retail, + total_revenue = EXCLUDED.total_revenue, + avg_margin = EXCLUDED.avg_margin + `); + + processedCount = Math.floor(totalProducts * 0.99); + outputProgress({ + status: 'running', + operation: 'Brand time-based metrics calculated', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // If we get here, everything completed successfully + success = true; + + // Update calculate_status + await connection.query(` + INSERT INTO calculate_status (module_name, last_calculation_timestamp) + VALUES ('brand_metrics', NOW()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = NOW() + `); + + return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + } catch (error) { + success = false; + logError(error, 'Error calculating brand metrics'); + throw error; + } finally { + if (connection) { + connection.release(); + } + } +} + +module.exports = calculateBrandMetrics; \ No newline at end of file diff --git a/inventory-server/old/metrics/category-metrics.js b/inventory-server/old/metrics/category-metrics.js new file mode 100644 index 0000000..027dd8e --- /dev/null +++ b/inventory-server/old/metrics/category-metrics.js @@ -0,0 +1,554 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); +const { getConnection } = require('./utils/db'); + +async function calculateCategoryMetrics(startTime, totalProducts, processedCount = 0, isCancelled = false) { + const connection = await getConnection(); + let success = false; + let processedOrders = 0; + + try { + if (isCancelled) { + outputProgress({ + status: 'cancelled', + operation: 'Category metrics calculation cancelled', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: null, + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + return { + processedProducts: processedCount, + processedOrders: 0, + processedPurchaseOrders: 0, + success + }; + } + + // Get order count that will be processed + const orderCount = await connection.query(` + SELECT COUNT(*) as count + FROM orders o + WHERE o.canceled = false + `); + processedOrders = parseInt(orderCount.rows[0].count); + + outputProgress({ + status: 'running', + operation: 'Starting category metrics calculation', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // First, calculate base category metrics + await connection.query(` + INSERT INTO category_metrics ( + category_id, + product_count, + active_products, + total_value, + status, + last_calculated_at + ) + SELECT + c.cat_id, + COUNT(DISTINCT p.pid) as product_count, + COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products, + COALESCE(SUM(p.stock_quantity * p.cost_price), 0) as total_value, + c.status, + NOW() as last_calculated_at + FROM categories c + LEFT JOIN product_categories pc ON c.cat_id = pc.cat_id + LEFT JOIN products p ON pc.pid = p.pid + GROUP BY c.cat_id, c.status + ON CONFLICT (category_id) DO UPDATE + SET + product_count = EXCLUDED.product_count, + active_products = EXCLUDED.active_products, + total_value = EXCLUDED.total_value, + status = EXCLUDED.status, + last_calculated_at = EXCLUDED.last_calculated_at + `); + + processedCount = Math.floor(totalProducts * 0.90); + outputProgress({ + status: 'running', + operation: 'Base category metrics calculated, updating with margin data', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Then update with margin and turnover data + await connection.query(` + WITH category_sales AS ( + SELECT + pc.cat_id, + SUM(o.quantity * o.price) as total_sales, + SUM(o.quantity * (o.price - p.cost_price)) as total_margin, + SUM(o.quantity) as units_sold, + AVG(GREATEST(p.stock_quantity, 0)) as avg_stock, + COUNT(DISTINCT DATE(o.date)) as active_days + FROM product_categories pc + JOIN products p ON pc.pid = p.pid + JOIN orders o ON p.pid = o.pid + LEFT JOIN turnover_config tc ON + (tc.category_id = pc.cat_id AND tc.vendor = p.vendor) OR + (tc.category_id = pc.cat_id AND tc.vendor IS NULL) OR + (tc.category_id IS NULL AND tc.vendor = p.vendor) OR + (tc.category_id IS NULL AND tc.vendor IS NULL) + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - (COALESCE(tc.calculation_period_days, 30) || ' days')::INTERVAL + GROUP BY pc.cat_id + ) + UPDATE category_metrics + SET + avg_margin = COALESCE(cs.total_margin * 100.0 / NULLIF(cs.total_sales, 0), 0), + turnover_rate = CASE + WHEN cs.avg_stock > 0 AND cs.active_days > 0 + THEN LEAST( + (cs.units_sold / cs.avg_stock) * (365.0 / cs.active_days), + 999.99 + ) + ELSE 0 + END, + last_calculated_at = NOW() + FROM category_sales cs + WHERE category_id = cs.cat_id + `); + + processedCount = Math.floor(totalProducts * 0.95); + outputProgress({ + status: 'running', + operation: 'Margin data updated, calculating growth rates', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Finally update growth rates + await connection.query(` + WITH current_period AS ( + SELECT + pc.cat_id, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0)) / + (1 + COALESCE(ss.seasonality_factor, 0))) as revenue, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0) - p.cost_price)) as gross_profit, + COUNT(DISTINCT DATE(o.date)) as days + FROM product_categories pc + JOIN products p ON pc.pid = p.pid + JOIN orders o ON p.pid = o.pid + LEFT JOIN sales_seasonality ss ON EXTRACT(MONTH FROM o.date) = ss.month + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '3 months' + GROUP BY pc.cat_id + ), + previous_period AS ( + SELECT + pc.cat_id, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0)) / + (1 + COALESCE(ss.seasonality_factor, 0))) as revenue, + COUNT(DISTINCT DATE(o.date)) as days + FROM product_categories pc + JOIN products p ON pc.pid = p.pid + JOIN orders o ON p.pid = o.pid + LEFT JOIN sales_seasonality ss ON EXTRACT(MONTH FROM o.date) = ss.month + WHERE o.canceled = false + AND o.date BETWEEN CURRENT_DATE - INTERVAL '15 months' + AND CURRENT_DATE - INTERVAL '12 months' + GROUP BY pc.cat_id + ), + trend_data AS ( + SELECT + pc.cat_id, + EXTRACT(MONTH FROM o.date) as month, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0)) / + (1 + COALESCE(ss.seasonality_factor, 0))) as revenue, + COUNT(DISTINCT DATE(o.date)) as days_in_month + FROM product_categories pc + JOIN products p ON pc.pid = p.pid + JOIN orders o ON p.pid = o.pid + LEFT JOIN sales_seasonality ss ON EXTRACT(MONTH FROM o.date) = ss.month + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '15 months' + GROUP BY pc.cat_id, EXTRACT(MONTH FROM o.date) + ), + trend_stats AS ( + SELECT + cat_id, + COUNT(*) as n, + AVG(month) as avg_x, + AVG(revenue / NULLIF(days_in_month, 0)) as avg_y, + SUM(month * (revenue / NULLIF(days_in_month, 0))) as sum_xy, + SUM(month * month) as sum_xx + FROM trend_data + GROUP BY cat_id + HAVING COUNT(*) >= 6 + ), + trend_analysis AS ( + SELECT + cat_id, + ((n * sum_xy) - (avg_x * n * avg_y)) / + NULLIF((n * sum_xx) - (n * avg_x * avg_x), 0) as trend_slope, + avg_y as avg_daily_revenue + FROM trend_stats + ), + margin_calc AS ( + SELECT + pc.cat_id, + CASE + WHEN SUM(o.quantity * o.price) > 0 THEN + GREATEST( + -100.0, + LEAST( + 100.0, + ( + SUM(o.quantity * o.price) - -- Use gross revenue (before discounts) + SUM(o.quantity * COALESCE(p.cost_price, 0)) -- Total costs + ) * 100.0 / + NULLIF(SUM(o.quantity * o.price), 0) -- Divide by gross revenue + ) + ) + ELSE NULL + END as avg_margin + FROM product_categories pc + JOIN products p ON pc.pid = p.pid + JOIN orders o ON p.pid = o.pid + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '3 months' + GROUP BY pc.cat_id + ), + combined_metrics AS ( + SELECT + COALESCE(cp.cat_id, pp.cat_id) as category_id, + CASE + WHEN pp.revenue = 0 AND COALESCE(cp.revenue, 0) > 0 THEN 100.0 + WHEN pp.revenue = 0 OR cp.revenue IS NULL THEN 0.0 + WHEN ta.trend_slope IS NOT NULL THEN + GREATEST( + -100.0, + LEAST( + (ta.trend_slope / NULLIF(ta.avg_daily_revenue, 0)) * 365 * 100, + 999.99 + ) + ) + ELSE + GREATEST( + -100.0, + LEAST( + ((COALESCE(cp.revenue, 0) - pp.revenue) / + NULLIF(ABS(pp.revenue), 0)) * 100.0, + 999.99 + ) + ) + END as growth_rate, + mc.avg_margin + FROM current_period cp + FULL OUTER JOIN previous_period pp ON cp.cat_id = pp.cat_id + LEFT JOIN trend_analysis ta ON COALESCE(cp.cat_id, pp.cat_id) = ta.cat_id + LEFT JOIN margin_calc mc ON COALESCE(cp.cat_id, pp.cat_id) = mc.cat_id + ) + UPDATE category_metrics cm + SET + growth_rate = CASE + WHEN pp.revenue = 0 AND COALESCE(cp.revenue, 0) > 0 THEN 100.0 + WHEN pp.revenue = 0 OR cp.revenue IS NULL THEN 0.0 + WHEN ta.trend_slope IS NOT NULL THEN + GREATEST( + -100.0, + LEAST( + (ta.trend_slope / NULLIF(ta.avg_daily_revenue, 0)) * 365 * 100, + 999.99 + ) + ) + ELSE + GREATEST( + -100.0, + LEAST( + ((COALESCE(cp.revenue, 0) - pp.revenue) / + NULLIF(ABS(pp.revenue), 0)) * 100.0, + 999.99 + ) + ) + END, + avg_margin = COALESCE(mc.avg_margin, cm.avg_margin), + last_calculated_at = NOW() + FROM current_period cp + FULL OUTER JOIN previous_period pp ON cp.cat_id = pp.cat_id + LEFT JOIN trend_analysis ta ON COALESCE(cp.cat_id, pp.cat_id) = ta.cat_id + LEFT JOIN margin_calc mc ON COALESCE(cp.cat_id, pp.cat_id) = mc.cat_id + WHERE cm.category_id = COALESCE(cp.cat_id, pp.cat_id) + `); + + processedCount = Math.floor(totalProducts * 0.97); + outputProgress({ + status: 'running', + operation: 'Growth rates calculated, updating time-based metrics', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Calculate time-based metrics + await connection.query(` + INSERT INTO category_time_metrics ( + category_id, + year, + month, + product_count, + active_products, + total_value, + total_revenue, + avg_margin, + turnover_rate + ) + SELECT + pc.cat_id, + EXTRACT(YEAR FROM o.date::timestamp with time zone) as year, + EXTRACT(MONTH FROM o.date::timestamp with time zone) as month, + COUNT(DISTINCT p.pid) as product_count, + COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products, + SUM(p.stock_quantity * p.cost_price) as total_value, + SUM(o.quantity * o.price) as total_revenue, + CASE + WHEN SUM(o.quantity * o.price) > 0 THEN + LEAST( + GREATEST( + SUM(o.quantity * (o.price - GREATEST(p.cost_price, 0))) * 100.0 / + SUM(o.quantity * o.price), + -100 + ), + 100 + ) + ELSE 0 + END as avg_margin, + COALESCE( + LEAST( + SUM(o.quantity) / NULLIF(AVG(GREATEST(p.stock_quantity, 0)), 0), + 999.99 + ), + 0 + ) as turnover_rate + FROM product_categories pc + JOIN products p ON pc.pid = p.pid + JOIN orders o ON p.pid = o.pid + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '12 months' + GROUP BY pc.cat_id, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone) + ON CONFLICT (category_id, year, month) DO UPDATE + SET + product_count = EXCLUDED.product_count, + active_products = EXCLUDED.active_products, + total_value = EXCLUDED.total_value, + total_revenue = EXCLUDED.total_revenue, + avg_margin = EXCLUDED.avg_margin, + turnover_rate = EXCLUDED.turnover_rate + `); + + processedCount = Math.floor(totalProducts * 0.99); + outputProgress({ + status: 'running', + operation: 'Time-based metrics calculated, updating category-sales metrics', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Calculate category-sales metrics + await connection.query(` + INSERT INTO category_sales_metrics ( + category_id, + brand, + period_start, + period_end, + avg_daily_sales, + total_sold, + num_products, + avg_price, + last_calculated_at + ) + WITH date_ranges AS ( + SELECT + CURRENT_DATE - INTERVAL '30 days' as period_start, + CURRENT_DATE as period_end + UNION ALL + SELECT + CURRENT_DATE - INTERVAL '90 days', + CURRENT_DATE - INTERVAL '31 days' + UNION ALL + SELECT + CURRENT_DATE - INTERVAL '180 days', + CURRENT_DATE - INTERVAL '91 days' + UNION ALL + SELECT + CURRENT_DATE - INTERVAL '365 days', + CURRENT_DATE - INTERVAL '181 days' + ), + sales_data AS ( + SELECT + pc.cat_id, + COALESCE(p.brand, 'Unknown') as brand, + dr.period_start, + dr.period_end, + COUNT(DISTINCT p.pid) as num_products, + SUM(o.quantity) as total_sold, + SUM(o.quantity * o.price) as total_revenue, + COUNT(DISTINCT DATE(o.date)) as num_days + FROM products p + JOIN product_categories pc ON p.pid = pc.pid + JOIN orders o ON p.pid = o.pid + CROSS JOIN date_ranges dr + WHERE o.canceled = false + AND o.date BETWEEN dr.period_start AND dr.period_end + GROUP BY pc.cat_id, p.brand, dr.period_start, dr.period_end + ) + SELECT + cat_id as category_id, + brand, + period_start, + period_end, + CASE + WHEN num_days > 0 + THEN total_sold / num_days + ELSE 0 + END as avg_daily_sales, + total_sold, + num_products, + CASE + WHEN total_sold > 0 + THEN total_revenue / total_sold + ELSE 0 + END as avg_price, + NOW() as last_calculated_at + FROM sales_data + ON CONFLICT (category_id, brand, period_start, period_end) DO UPDATE + SET + avg_daily_sales = EXCLUDED.avg_daily_sales, + total_sold = EXCLUDED.total_sold, + num_products = EXCLUDED.num_products, + avg_price = EXCLUDED.avg_price, + last_calculated_at = EXCLUDED.last_calculated_at + `); + + processedCount = Math.floor(totalProducts * 1.0); + outputProgress({ + status: 'running', + operation: 'Category-sales metrics calculated', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // If we get here, everything completed successfully + success = true; + + // Update calculate_status + await connection.query(` + INSERT INTO calculate_status (module_name, last_calculation_timestamp) + VALUES ('category_metrics', NOW()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = NOW() + `); + + return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + } catch (error) { + success = false; + logError(error, 'Error calculating category metrics'); + throw error; + } finally { + if (connection) { + connection.release(); + } + } +} + +module.exports = calculateCategoryMetrics; \ No newline at end of file diff --git a/inventory-server/old/metrics/financial-metrics.js b/inventory-server/old/metrics/financial-metrics.js new file mode 100644 index 0000000..a683155 --- /dev/null +++ b/inventory-server/old/metrics/financial-metrics.js @@ -0,0 +1,214 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); +const { getConnection } = require('./utils/db'); + +async function calculateFinancialMetrics(startTime, totalProducts, processedCount = 0, isCancelled = false) { + const connection = await getConnection(); + let success = false; + let processedOrders = 0; + + try { + if (isCancelled) { + outputProgress({ + status: 'cancelled', + operation: 'Financial metrics calculation cancelled', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: null, + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + return { + processedProducts: processedCount, + processedOrders: 0, + processedPurchaseOrders: 0, + success + }; + } + + // Get order count that will be processed + const orderCount = await connection.query(` + SELECT COUNT(*) as count + FROM orders o + WHERE o.canceled = false + AND DATE(o.date) >= CURRENT_DATE - INTERVAL '12 months' + `); + processedOrders = parseInt(orderCount.rows[0].count); + + outputProgress({ + status: 'running', + operation: 'Starting financial metrics calculation', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // First, calculate beginning inventory values (12 months ago) + await connection.query(` + CREATE TEMPORARY TABLE IF NOT EXISTS temp_beginning_inventory AS + WITH beginning_inventory_calc AS ( + SELECT + p.pid, + p.stock_quantity as current_quantity, + COALESCE(SUM(o.quantity), 0) as sold_quantity, + COALESCE(SUM(po.received), 0) as received_quantity, + GREATEST(0, (p.stock_quantity + COALESCE(SUM(o.quantity), 0) - COALESCE(SUM(po.received), 0))) as beginning_quantity, + p.cost_price + FROM + products p + LEFT JOIN + orders o ON p.pid = o.pid + AND o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '12 months'::interval + LEFT JOIN + purchase_orders po ON p.pid = po.pid + AND po.received_date IS NOT NULL + AND po.received_date >= CURRENT_DATE - INTERVAL '12 months'::interval + GROUP BY + p.pid, p.stock_quantity, p.cost_price + ) + SELECT + pid, + beginning_quantity, + beginning_quantity * cost_price as beginning_value, + current_quantity * cost_price as current_value, + ((beginning_quantity * cost_price) + (current_quantity * cost_price)) / 2 as average_inventory_value + FROM + beginning_inventory_calc + `); + + processedCount = Math.floor(totalProducts * 0.60); + outputProgress({ + status: 'running', + operation: 'Beginning inventory values calculated, computing financial metrics', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Calculate financial metrics with optimized query and standard formulas + await connection.query(` + WITH product_financials AS ( + SELECT + p.pid, + COALESCE(bi.average_inventory_value, p.cost_price * p.stock_quantity) as avg_inventory_value, + p.cost_price * p.stock_quantity as current_inventory_value, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0))) as total_revenue, + SUM(o.quantity * COALESCE(o.costeach, 0)) as cost_of_goods_sold, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0) - COALESCE(o.costeach, 0))) as gross_profit, + MIN(o.date) as first_sale_date, + MAX(o.date) as last_sale_date, + EXTRACT(DAY FROM (MAX(o.date)::timestamp with time zone - MIN(o.date)::timestamp with time zone)) + 1 as calculation_period_days, + COUNT(DISTINCT DATE(o.date)) as active_days + FROM products p + LEFT JOIN orders o ON p.pid = o.pid + LEFT JOIN temp_beginning_inventory bi ON p.pid = bi.pid + WHERE o.canceled = false + AND DATE(o.date) >= CURRENT_DATE - INTERVAL '12 months'::interval + GROUP BY p.pid, p.cost_price, p.stock_quantity, bi.average_inventory_value + ) + UPDATE product_metrics pm + SET + inventory_value = COALESCE(pf.current_inventory_value, 0)::decimal(10,3), + total_revenue = COALESCE(pf.total_revenue, 0)::decimal(10,3), + cost_of_goods_sold = COALESCE(pf.cost_of_goods_sold, 0)::decimal(10,3), + gross_profit = COALESCE(pf.gross_profit, 0)::decimal(10,3), + turnover_rate = CASE + WHEN COALESCE(pf.avg_inventory_value, 0) > 0 THEN + COALESCE(pf.cost_of_goods_sold, 0) / NULLIF(pf.avg_inventory_value, 0) + ELSE 0 + END::decimal(12,3), + gmroi = CASE + WHEN COALESCE(pf.avg_inventory_value, 0) > 0 THEN + COALESCE(pf.gross_profit, 0) / NULLIF(pf.avg_inventory_value, 0) + ELSE 0 + END::decimal(10,3), + last_calculated_at = CURRENT_TIMESTAMP + FROM product_financials pf + WHERE pm.pid = pf.pid + `); + + processedCount = Math.floor(totalProducts * 0.65); + outputProgress({ + status: 'running', + operation: 'Base financial metrics calculated, updating time aggregates', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Clean up temporary tables + await connection.query('DROP TABLE IF EXISTS temp_beginning_inventory'); + + // If we get here, everything completed successfully + success = true; + + // Update calculate_status + await connection.query(` + INSERT INTO calculate_status (module_name, last_calculation_timestamp) + VALUES ('financial_metrics', NOW()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = NOW() + `); + + return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + } catch (error) { + success = false; + logError(error, 'Error calculating financial metrics'); + throw error; + } finally { + if (connection) { + try { + // Make sure temporary tables are always cleaned up + await connection.query('DROP TABLE IF EXISTS temp_beginning_inventory'); + } catch (err) { + console.error('Error cleaning up temp tables:', err); + } + connection.release(); + } + } +} + +module.exports = calculateFinancialMetrics; \ No newline at end of file diff --git a/inventory-server/old/metrics/product-metrics.js b/inventory-server/old/metrics/product-metrics.js new file mode 100644 index 0000000..405e6d9 --- /dev/null +++ b/inventory-server/old/metrics/product-metrics.js @@ -0,0 +1,736 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); +const { getConnection } = require('./utils/db'); + +// Helper function to handle NaN and undefined values +function sanitizeValue(value) { + if (value === undefined || value === null || Number.isNaN(value)) { + return null; + } + return value; +} + +async function calculateProductMetrics(startTime, totalProducts, processedCount = 0, isCancelled = false) { + let connection; + let success = false; + let processedOrders = 0; + const BATCH_SIZE = 5000; + + try { + connection = await getConnection(); + // Skip flags are inherited from the parent scope + const SKIP_PRODUCT_BASE_METRICS = 0; + const SKIP_PRODUCT_TIME_AGGREGATES = 0; + + // Get total product count if not provided + if (!totalProducts) { + const productCount = await connection.query('SELECT COUNT(*) as count FROM products'); + totalProducts = parseInt(productCount.rows[0].count); + } + + if (isCancelled) { + outputProgress({ + status: 'cancelled', + operation: 'Product metrics calculation cancelled', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: null, + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + } + + // First ensure all products have a metrics record + await connection.query(` + INSERT INTO product_metrics (pid, last_calculated_at) + SELECT pid, NOW() + FROM products + ON CONFLICT (pid) DO NOTHING + `); + + // Get threshold settings once + const thresholds = await connection.query(` + SELECT critical_days, reorder_days, overstock_days, low_stock_threshold + FROM stock_thresholds + WHERE category_id IS NULL AND vendor IS NULL + LIMIT 1 + `); + + // Check if threshold data was returned + if (!thresholds.rows || thresholds.rows.length === 0) { + console.warn('No default thresholds found in the database. Using explicit type casting in the query.'); + } + + const defaultThresholds = thresholds.rows[0]; + + // Get financial calculation configuration parameters + const financialConfig = await connection.query(` + SELECT + order_cost, + holding_rate, + service_level_z_score, + min_reorder_qty, + default_reorder_qty, + default_safety_stock + FROM financial_calc_config + WHERE id = 1 + LIMIT 1 + `); + const finConfig = financialConfig.rows[0] || { + order_cost: 25.00, + holding_rate: 0.25, + service_level_z_score: 1.96, + min_reorder_qty: 1, + default_reorder_qty: 5, + default_safety_stock: 5 + }; + + // Calculate base product metrics + if (!SKIP_PRODUCT_BASE_METRICS) { + outputProgress({ + status: 'running', + operation: 'Starting base product metrics calculation', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Get order count that will be processed + const orderCount = await connection.query(` + SELECT COUNT(*) as count + FROM orders o + WHERE o.canceled = false + `); + processedOrders = parseInt(orderCount.rows[0].count); + + // Clear temporary tables + await connection.query('DROP TABLE IF EXISTS temp_sales_metrics'); + await connection.query('DROP TABLE IF EXISTS temp_purchase_metrics'); + + // Create temp_sales_metrics + await connection.query(` + CREATE TEMPORARY TABLE temp_sales_metrics ( + pid BIGINT NOT NULL, + daily_sales_avg DECIMAL(10,3), + weekly_sales_avg DECIMAL(10,3), + monthly_sales_avg DECIMAL(10,3), + total_revenue DECIMAL(10,3), + avg_margin_percent DECIMAL(10,3), + first_sale_date DATE, + last_sale_date DATE, + stddev_daily_sales DECIMAL(10,3), + PRIMARY KEY (pid) + ) + `); + + // Create temp_purchase_metrics + await connection.query(` + CREATE TEMPORARY TABLE temp_purchase_metrics ( + pid BIGINT NOT NULL, + avg_lead_time_days DECIMAL(10,2), + last_purchase_date DATE, + first_received_date DATE, + last_received_date DATE, + stddev_lead_time_days DECIMAL(10,2), + PRIMARY KEY (pid) + ) + `); + + // Populate temp_sales_metrics with base stats and sales averages + await connection.query(` + INSERT INTO temp_sales_metrics + SELECT + p.pid, + COALESCE(SUM(o.quantity) / NULLIF(COUNT(DISTINCT DATE(o.date)), 0), 0) as daily_sales_avg, + COALESCE(SUM(o.quantity) / NULLIF(CEIL(COUNT(DISTINCT DATE(o.date)) / 7), 0), 0) as weekly_sales_avg, + COALESCE(SUM(o.quantity) / NULLIF(CEIL(COUNT(DISTINCT DATE(o.date)) / 30), 0), 0) as monthly_sales_avg, + COALESCE(SUM(o.quantity * o.price), 0) as total_revenue, + CASE + WHEN SUM(o.quantity * o.price) > 0 + THEN ((SUM(o.quantity * o.price) - SUM(o.quantity * p.cost_price)) / SUM(o.quantity * o.price)) * 100 + ELSE 0 + END as avg_margin_percent, + MIN(o.date) as first_sale_date, + MAX(o.date) as last_sale_date, + COALESCE(STDDEV_SAMP(daily_qty.quantity), 0) as stddev_daily_sales + FROM products p + LEFT JOIN orders o ON p.pid = o.pid + AND o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '90 days' + LEFT JOIN ( + SELECT + pid, + DATE(date) as sale_date, + SUM(quantity) as quantity + FROM orders + WHERE canceled = false + AND date >= CURRENT_DATE - INTERVAL '90 days' + GROUP BY pid, DATE(date) + ) daily_qty ON p.pid = daily_qty.pid + GROUP BY p.pid + `); + + // Populate temp_purchase_metrics with timeout protection + await Promise.race([ + connection.query(` + INSERT INTO temp_purchase_metrics + SELECT + p.pid, + AVG( + CASE + WHEN po.received_date IS NOT NULL AND po.date IS NOT NULL + THEN EXTRACT(EPOCH FROM (po.received_date::timestamp with time zone - po.date::timestamp with time zone)) / 86400.0 + ELSE NULL + END + ) as avg_lead_time_days, + MAX(po.date) as last_purchase_date, + MIN(po.received_date) as first_received_date, + MAX(po.received_date) as last_received_date, + STDDEV_SAMP( + CASE + WHEN po.received_date IS NOT NULL AND po.date IS NOT NULL + THEN EXTRACT(EPOCH FROM (po.received_date::timestamp with time zone - po.date::timestamp with time zone)) / 86400.0 + ELSE NULL + END + ) as stddev_lead_time_days + FROM products p + LEFT JOIN purchase_orders po ON p.pid = po.pid + AND po.received_date IS NOT NULL + AND po.date IS NOT NULL + AND po.date >= CURRENT_DATE - INTERVAL '365 days' + GROUP BY p.pid + `), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout: temp_purchase_metrics query took too long')), 60000) + ) + ]).catch(async (err) => { + logError(err, 'Error populating temp_purchase_metrics, continuing with empty table'); + // Create an empty fallback to continue processing + await connection.query(` + INSERT INTO temp_purchase_metrics + SELECT + p.pid, + 30.0 as avg_lead_time_days, + NULL as last_purchase_date, + NULL as first_received_date, + NULL as last_received_date, + 0.0 as stddev_lead_time_days + FROM products p + LEFT JOIN temp_purchase_metrics tpm ON p.pid = tpm.pid + WHERE tpm.pid IS NULL + `); + }); + + // Process updates in batches + let lastPid = 0; + let batchCount = 0; + const MAX_BATCHES = 1000; // Safety limit for number of batches to prevent infinite loops + + while (batchCount < MAX_BATCHES) { + if (isCancelled) break; + + batchCount++; + const batch = await connection.query( + 'SELECT pid FROM products WHERE pid > $1 ORDER BY pid LIMIT $2', + [lastPid, BATCH_SIZE] + ); + + if (batch.rows.length === 0) break; + + // Process the entire batch in a single efficient query + const lowStockThreshold = parseInt(defaultThresholds?.low_stock_threshold) || 5; + const criticalDays = parseInt(defaultThresholds?.critical_days) || 7; + const reorderDays = parseInt(defaultThresholds?.reorder_days) || 14; + const overstockDays = parseInt(defaultThresholds?.overstock_days) || 90; + const serviceLevel = parseFloat(finConfig?.service_level_z_score) || 1.96; + const defaultSafetyStock = parseInt(finConfig?.default_safety_stock) || 5; + const defaultReorderQty = parseInt(finConfig?.default_reorder_qty) || 5; + const orderCost = parseFloat(finConfig?.order_cost) || 25.00; + const holdingRate = parseFloat(finConfig?.holding_rate) || 0.25; + const minReorderQty = parseInt(finConfig?.min_reorder_qty) || 1; + + await connection.query(` + UPDATE product_metrics pm + SET + inventory_value = p.stock_quantity * NULLIF(p.cost_price, 0), + daily_sales_avg = COALESCE(sm.daily_sales_avg, 0), + weekly_sales_avg = COALESCE(sm.weekly_sales_avg, 0), + monthly_sales_avg = COALESCE(sm.monthly_sales_avg, 0), + total_revenue = COALESCE(sm.total_revenue, 0), + avg_margin_percent = COALESCE(sm.avg_margin_percent, 0), + first_sale_date = sm.first_sale_date, + last_sale_date = sm.last_sale_date, + avg_lead_time_days = COALESCE(lm.avg_lead_time_days, 30.0), + days_of_inventory = CASE + WHEN COALESCE(sm.daily_sales_avg, 0) > 0 + THEN FLOOR(p.stock_quantity / NULLIF(sm.daily_sales_avg, 0)) + ELSE NULL + END, + weeks_of_inventory = CASE + WHEN COALESCE(sm.weekly_sales_avg, 0) > 0 + THEN FLOOR(p.stock_quantity / NULLIF(sm.weekly_sales_avg, 0)) + ELSE NULL + END, + stock_status = CASE + WHEN p.stock_quantity <= 0 THEN 'Out of Stock' + WHEN COALESCE(sm.daily_sales_avg, 0) = 0 AND p.stock_quantity <= ${lowStockThreshold} THEN 'Low Stock' + WHEN COALESCE(sm.daily_sales_avg, 0) = 0 THEN 'In Stock' + WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= ${criticalDays} THEN 'Critical' + WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= ${reorderDays} THEN 'Reorder' + WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ${overstockDays} THEN 'Overstocked' + ELSE 'Healthy' + END, + safety_stock = CASE + WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND COALESCE(lm.avg_lead_time_days, 0) > 0 THEN + CEIL( + ${serviceLevel} * SQRT( + GREATEST(0, COALESCE(lm.avg_lead_time_days, 0)) * POWER(COALESCE(sm.stddev_daily_sales, 0), 2) + + POWER(COALESCE(sm.daily_sales_avg, 0), 2) * POWER(COALESCE(lm.stddev_lead_time_days, 0), 2) + ) + ) + ELSE ${defaultSafetyStock} + END, + reorder_point = CASE + WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN + CEIL(sm.daily_sales_avg * GREATEST(0, COALESCE(lm.avg_lead_time_days, 30.0))) + + (CASE + WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND COALESCE(lm.avg_lead_time_days, 0) > 0 THEN + CEIL( + ${serviceLevel} * SQRT( + GREATEST(0, COALESCE(lm.avg_lead_time_days, 0)) * POWER(COALESCE(sm.stddev_daily_sales, 0), 2) + + POWER(COALESCE(sm.daily_sales_avg, 0), 2) * POWER(COALESCE(lm.stddev_lead_time_days, 0), 2) + ) + ) + ELSE ${defaultSafetyStock} + END) + ELSE ${lowStockThreshold} + END, + reorder_qty = CASE + WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND NULLIF(p.cost_price, 0) IS NOT NULL AND NULLIF(p.cost_price, 0) > 0 THEN + GREATEST( + CEIL(SQRT( + (2 * (sm.daily_sales_avg * 365) * ${orderCost}) / + NULLIF(p.cost_price * ${holdingRate}, 0) + )), + ${minReorderQty} + ) + ELSE ${defaultReorderQty} + END, + overstocked_amt = CASE + WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ${overstockDays} + THEN GREATEST(0, p.stock_quantity - CEIL(sm.daily_sales_avg * ${overstockDays})) + ELSE 0 + END, + last_calculated_at = NOW() + FROM products p + LEFT JOIN temp_sales_metrics sm ON p.pid = sm.pid + LEFT JOIN temp_purchase_metrics lm ON p.pid = lm.pid + WHERE p.pid = ANY($1::BIGINT[]) + AND pm.pid = p.pid + `, [batch.rows.map(row => row.pid)]); + + lastPid = batch.rows[batch.rows.length - 1].pid; + processedCount += batch.rows.length; + + outputProgress({ + status: 'running', + operation: 'Processing base metrics batch', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + } + + // Add safety check if the loop processed MAX_BATCHES + if (batchCount >= MAX_BATCHES) { + logError(new Error(`Reached maximum batch count (${MAX_BATCHES}). Process may have entered an infinite loop.`), 'Batch processing safety limit reached'); + } + } + + // Calculate forecast accuracy and bias in batches + let forecastPid = 0; + while (true) { + if (isCancelled) break; + + const forecastBatch = await connection.query( + 'SELECT pid FROM products WHERE pid > $1 ORDER BY pid LIMIT $2', + [forecastPid, BATCH_SIZE] + ); + + if (forecastBatch.rows.length === 0) break; + + const forecastPidArray = forecastBatch.rows.map(row => row.pid); + + // Use array_to_string to convert the array to a string of comma-separated values + await connection.query(` + WITH forecast_metrics AS ( + SELECT + sf.pid, + AVG(CASE + WHEN o.quantity > 0 + THEN ABS(sf.forecast_quantity - o.quantity) / o.quantity * 100 + ELSE 100 + END) as avg_forecast_error, + AVG(CASE + WHEN o.quantity > 0 + THEN (sf.forecast_quantity - o.quantity) / o.quantity * 100 + ELSE 0 + END) as avg_forecast_bias, + MAX(sf.forecast_date) as last_forecast_date + FROM sales_forecasts sf + JOIN orders o ON sf.pid = o.pid + AND DATE(o.date) = sf.forecast_date + WHERE o.canceled = false + AND sf.forecast_date >= CURRENT_DATE - INTERVAL '90 days' + AND sf.pid = ANY('{${forecastPidArray.join(',')}}'::BIGINT[]) + GROUP BY sf.pid + ) + UPDATE product_metrics pm + SET + forecast_accuracy = GREATEST(0, 100 - LEAST(fm.avg_forecast_error, 100)), + forecast_bias = GREATEST(-100, LEAST(fm.avg_forecast_bias, 100)), + last_forecast_date = fm.last_forecast_date, + last_calculated_at = NOW() + FROM forecast_metrics fm + WHERE pm.pid = fm.pid + `); + + forecastPid = forecastBatch.rows[forecastBatch.rows.length - 1].pid; + } + + // Calculate product time aggregates + if (!SKIP_PRODUCT_TIME_AGGREGATES) { + outputProgress({ + status: 'running', + operation: 'Starting product time aggregates calculation', + current: processedCount || 0, + total: totalProducts || 0, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount || 0, totalProducts || 0), + rate: calculateRate(startTime, processedCount || 0), + percentage: (((processedCount || 0) / (totalProducts || 1)) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Note: The time-aggregates calculation has been moved to time-aggregates.js + // This module will not duplicate that functionality + processedCount = Math.floor(totalProducts * 0.6); + outputProgress({ + status: 'running', + operation: 'Product time aggregates calculation delegated to time-aggregates module', + current: processedCount || 0, + total: totalProducts || 0, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount || 0, totalProducts || 0), + rate: calculateRate(startTime, processedCount || 0), + percentage: (((processedCount || 0) / (totalProducts || 1)) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + } else { + processedCount = Math.floor(totalProducts * 0.6); + outputProgress({ + status: 'running', + operation: 'Skipping product time aggregates calculation', + current: processedCount || 0, + total: totalProducts || 0, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount || 0, totalProducts || 0), + rate: calculateRate(startTime, processedCount || 0), + percentage: (((processedCount || 0) / (totalProducts || 1)) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + } + + // Calculate ABC classification + outputProgress({ + status: 'running', + operation: 'Starting ABC classification', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, // This module doesn't process POs + success + }; + + const abcConfig = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1'); + const abcThresholds = abcConfig.rows[0] || { a_threshold: 20, b_threshold: 50 }; + + // Extract values and ensure they are valid numbers + const aThreshold = parseFloat(abcThresholds.a_threshold) || 20; + const bThreshold = parseFloat(abcThresholds.b_threshold) || 50; + + // First, create and populate the rankings table with an index + await connection.query('DROP TABLE IF EXISTS temp_revenue_ranks'); + await connection.query(` + CREATE TEMPORARY TABLE temp_revenue_ranks ( + pid BIGINT NOT NULL, + total_revenue DECIMAL(10,3), + rank_num INT, + dense_rank_num INT, + percentile DECIMAL(5,2), + total_count INT, + PRIMARY KEY (pid) + ) + `); + await connection.query('CREATE INDEX ON temp_revenue_ranks (rank_num)'); + await connection.query('CREATE INDEX ON temp_revenue_ranks (dense_rank_num)'); + await connection.query('CREATE INDEX ON temp_revenue_ranks (percentile)'); + + // Calculate rankings with proper tie handling + await connection.query(` + INSERT INTO temp_revenue_ranks + WITH revenue_data AS ( + SELECT + pid, + total_revenue, + COUNT(*) OVER () as total_count, + PERCENT_RANK() OVER (ORDER BY total_revenue DESC) * 100 as percentile, + RANK() OVER (ORDER BY total_revenue DESC) as rank_num, + DENSE_RANK() OVER (ORDER BY total_revenue DESC) as dense_rank_num + FROM product_metrics + WHERE total_revenue > 0 + ) + SELECT + pid, + total_revenue, + rank_num, + dense_rank_num, + percentile, + total_count + FROM revenue_data + `); + + // Get total count for percentage calculation + const rankingCount = await connection.query('SELECT MAX(rank_num) as total_count FROM temp_revenue_ranks'); + const totalCount = parseInt(rankingCount.rows[0].total_count) || 1; + + // Process updates in batches + let abcProcessedCount = 0; + const batchSize = 5000; + const maxPid = await connection.query('SELECT MAX(pid) as max_pid FROM products'); + const maxProductId = parseInt(maxPid.rows[0].max_pid); + + while (abcProcessedCount < maxProductId) { + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Get a batch of PIDs that need updating + const pids = await connection.query(` + SELECT pm.pid + FROM product_metrics pm + LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid + WHERE pm.pid > $1 + AND (pm.abc_class IS NULL + OR pm.abc_class != + CASE + WHEN tr.pid IS NULL THEN 'C' + WHEN tr.percentile <= ${aThreshold} THEN 'A' + WHEN tr.percentile <= ${bThreshold} THEN 'B' + ELSE 'C' + END) + ORDER BY pm.pid + LIMIT $2 + `, [abcProcessedCount, batchSize]); + + if (pids.rows.length === 0) break; + + const pidValues = pids.rows.map(row => row.pid); + + await connection.query(` + UPDATE product_metrics pm + SET abc_class = + CASE + WHEN tr.pid IS NULL THEN 'C' + WHEN tr.percentile <= ${aThreshold} THEN 'A' + WHEN tr.percentile <= ${bThreshold} THEN 'B' + ELSE 'C' + END, + last_calculated_at = NOW() + FROM (SELECT pid, percentile FROM temp_revenue_ranks) tr + WHERE pm.pid = tr.pid AND pm.pid = ANY($1::BIGINT[]) + OR (pm.pid = ANY($1::BIGINT[]) AND tr.pid IS NULL) + `, [pidValues]); + + // Now update turnover rate with proper handling of zero inventory periods + await connection.query(` + UPDATE product_metrics pm + SET + turnover_rate = CASE + WHEN sales.avg_nonzero_stock > 0 AND sales.active_days > 0 + THEN LEAST( + (sales.total_sold / sales.avg_nonzero_stock) * (365.0 / sales.active_days), + 999.99 + ) + ELSE 0 + END, + last_calculated_at = NOW() + FROM ( + SELECT + o.pid, + SUM(o.quantity) as total_sold, + COUNT(DISTINCT DATE(o.date)) as active_days, + AVG(CASE + WHEN p.stock_quantity > 0 THEN p.stock_quantity + ELSE NULL + END) as avg_nonzero_stock + FROM orders o + JOIN products p ON o.pid = p.pid + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '90 days' + AND o.pid = ANY($1::BIGINT[]) + GROUP BY o.pid + ) sales + WHERE pm.pid = sales.pid + `, [pidValues]); + + abcProcessedCount = pids.rows[pids.rows.length - 1].pid; + + // Calculate progress proportionally to total products + processedCount = Math.floor(totalProducts * (0.60 + (abcProcessedCount / maxProductId) * 0.2)); + + outputProgress({ + status: 'running', + operation: 'ABC classification progress', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + } + + // If we get here, everything completed successfully + success = true; + + // Update calculate_status + await connection.query(` + INSERT INTO calculate_status (module_name, last_calculation_timestamp) + VALUES ('product_metrics', NOW()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = NOW() + `); + + return { + processedProducts: processedCount || 0, + processedOrders: processedOrders || 0, + processedPurchaseOrders: 0, // This module doesn't process POs + success + }; + + } catch (error) { + success = false; + logError(error, 'Error calculating product metrics'); + throw error; + } finally { + // Always clean up temporary tables, even if an error occurred + if (connection) { + try { + await connection.query('DROP TABLE IF EXISTS temp_sales_metrics'); + await connection.query('DROP TABLE IF EXISTS temp_purchase_metrics'); + } catch (err) { + console.error('Error cleaning up temporary tables:', err); + } + + // Make sure to release the connection + connection.release(); + } + } +} + +function calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg) { + if (stock <= 0) { + return 'Out of Stock'; + } + + // Use the most appropriate sales average based on data quality + let sales_avg = daily_sales_avg; + if (sales_avg === 0) { + sales_avg = weekly_sales_avg / 7; + } + if (sales_avg === 0) { + sales_avg = monthly_sales_avg / 30; + } + + if (sales_avg === 0) { + return stock <= config.low_stock_threshold ? 'Low Stock' : 'In Stock'; + } + + const days_of_stock = stock / sales_avg; + + if (days_of_stock <= config.critical_days) { + return 'Critical'; + } else if (days_of_stock <= config.reorder_days) { + return 'Reorder'; + } else if (days_of_stock > config.overstock_days) { + return 'Overstocked'; + } + + return 'Healthy'; +} + +// Note: calculateReorderQuantities function has been removed as its logic has been incorporated +// in the main SQL query with configurable parameters + +module.exports = calculateProductMetrics; \ No newline at end of file diff --git a/inventory-server/old/metrics/sales-forecasts.js b/inventory-server/old/metrics/sales-forecasts.js new file mode 100644 index 0000000..2734e41 --- /dev/null +++ b/inventory-server/old/metrics/sales-forecasts.js @@ -0,0 +1,440 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); +const { getConnection } = require('./utils/db'); + +async function calculateSalesForecasts(startTime, totalProducts, processedCount = 0, isCancelled = false) { + const connection = await getConnection(); + let success = false; + let processedOrders = 0; + + try { + if (isCancelled) { + outputProgress({ + status: 'cancelled', + operation: 'Sales forecasts calculation cancelled', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: null, + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + return { + processedProducts: processedCount, + processedOrders: 0, + processedPurchaseOrders: 0, + success + }; + } + + // Get order count that will be processed + const orderCount = await connection.query(` + SELECT COUNT(*) as count + FROM orders o + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '90 days' + `); + processedOrders = parseInt(orderCount.rows[0].count); + + outputProgress({ + status: 'running', + operation: 'Starting sales forecasts calculation', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // First, create a temporary table for forecast dates + await connection.query(` + CREATE TEMPORARY TABLE IF NOT EXISTS temp_forecast_dates ( + forecast_date DATE, + day_of_week INT, + month INT, + PRIMARY KEY (forecast_date) + ) + `); + + await connection.query(` + INSERT INTO temp_forecast_dates + SELECT + CURRENT_DATE + (n || ' days')::INTERVAL as forecast_date, + EXTRACT(DOW FROM CURRENT_DATE + (n || ' days')::INTERVAL) + 1 as day_of_week, + EXTRACT(MONTH FROM CURRENT_DATE + (n || ' days')::INTERVAL) as month + FROM ( + SELECT a.n + b.n * 10 as n + FROM + (SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION + SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a, + (SELECT 0 as n UNION SELECT 1 UNION SELECT 2) b + ORDER BY n + LIMIT 31 + ) numbers + `); + + processedCount = Math.floor(totalProducts * 0.92); + outputProgress({ + status: 'running', + operation: 'Forecast dates prepared, calculating daily sales stats', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Create temporary table for daily sales stats + await connection.query(` + CREATE TEMPORARY TABLE temp_daily_sales AS + SELECT + o.pid, + EXTRACT(DOW FROM o.date) + 1 as day_of_week, + SUM(o.quantity) as daily_quantity, + SUM(o.price * o.quantity) as daily_revenue, + COUNT(DISTINCT DATE(o.date)) as day_count + FROM orders o + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '90 days' + GROUP BY o.pid, EXTRACT(DOW FROM o.date) + 1 + `); + + processedCount = Math.floor(totalProducts * 0.94); + outputProgress({ + status: 'running', + operation: 'Daily sales stats calculated, preparing product stats', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Create temporary table for product stats + await connection.query(` + CREATE TEMPORARY TABLE temp_product_stats AS + SELECT + pid, + AVG(daily_revenue) as overall_avg_revenue, + SUM(day_count) as total_days + FROM temp_daily_sales + GROUP BY pid + `); + + processedCount = Math.floor(totalProducts * 0.96); + outputProgress({ + status: 'running', + operation: 'Product stats prepared, calculating product-level forecasts', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Calculate product-level forecasts + await connection.query(` + INSERT INTO sales_forecasts ( + pid, + forecast_date, + forecast_quantity, + confidence_level, + created_at + ) + WITH daily_stats AS ( + SELECT + ds.pid, + AVG(ds.daily_quantity) as avg_daily_qty, + STDDEV(ds.daily_quantity) as std_daily_qty, + COUNT(DISTINCT ds.day_count) as data_points, + SUM(ds.day_count) as total_days, + AVG(ds.daily_revenue) as avg_daily_revenue, + STDDEV(ds.daily_revenue) as std_daily_revenue, + MIN(ds.daily_quantity) as min_daily_qty, + MAX(ds.daily_quantity) as max_daily_qty, + -- Calculate variance without using LAG + COALESCE( + STDDEV(ds.daily_quantity) / NULLIF(AVG(ds.daily_quantity), 0), + 0 + ) as daily_variance_ratio + FROM temp_daily_sales ds + GROUP BY ds.pid + HAVING AVG(ds.daily_quantity) > 0 + ) + SELECT + ds.pid, + fd.forecast_date, + GREATEST(0, + ROUND( + ds.avg_daily_qty * + (1 + COALESCE(sf.seasonality_factor, 0)) + ) + ) as forecast_quantity, + CASE + WHEN ds.total_days >= 60 AND ds.daily_variance_ratio < 0.5 THEN 90 + WHEN ds.total_days >= 60 THEN 85 + WHEN ds.total_days >= 30 AND ds.daily_variance_ratio < 0.5 THEN 80 + WHEN ds.total_days >= 30 THEN 75 + WHEN ds.total_days >= 14 AND ds.daily_variance_ratio < 0.5 THEN 70 + WHEN ds.total_days >= 14 THEN 65 + ELSE 60 + END as confidence_level, + NOW() as created_at + FROM daily_stats ds + JOIN temp_product_stats ps ON ds.pid = ps.pid + CROSS JOIN temp_forecast_dates fd + LEFT JOIN sales_seasonality sf ON fd.month = sf.month + GROUP BY ds.pid, fd.forecast_date, ps.overall_avg_revenue, sf.seasonality_factor, + ds.avg_daily_qty, ds.std_daily_qty, ds.avg_daily_qty, ds.total_days, ds.daily_variance_ratio + ON CONFLICT (pid, forecast_date) DO UPDATE + SET + forecast_quantity = EXCLUDED.forecast_quantity, + confidence_level = EXCLUDED.confidence_level, + created_at = NOW() + `); + + processedCount = Math.floor(totalProducts * 0.98); + outputProgress({ + status: 'running', + operation: 'Product forecasts calculated, preparing category stats', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Create temporary table for category stats + await connection.query(` + CREATE TEMPORARY TABLE temp_category_sales AS + SELECT + pc.cat_id, + EXTRACT(DOW FROM o.date) + 1 as day_of_week, + SUM(o.quantity) as daily_quantity, + SUM(o.price * o.quantity) as daily_revenue, + COUNT(DISTINCT DATE(o.date)) as day_count + FROM orders o + JOIN product_categories pc ON o.pid = pc.pid + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '90 days' + GROUP BY pc.cat_id, EXTRACT(DOW FROM o.date) + 1 + `); + + await connection.query(` + CREATE TEMPORARY TABLE temp_category_stats AS + SELECT + cat_id, + AVG(daily_revenue) as overall_avg_revenue, + SUM(day_count) as total_days + FROM temp_category_sales + GROUP BY cat_id + `); + + processedCount = Math.floor(totalProducts * 0.99); + outputProgress({ + status: 'running', + operation: 'Category stats prepared, calculating category-level forecasts', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Calculate category-level forecasts + await connection.query(` + INSERT INTO category_forecasts ( + category_id, + forecast_date, + forecast_units, + forecast_revenue, + confidence_level, + created_at + ) + SELECT + cs.cat_id::bigint as category_id, + fd.forecast_date, + GREATEST(0, + ROUND(AVG(cs.daily_quantity) * + (1 + COALESCE(sf.seasonality_factor, 0))) + ) as forecast_units, + GREATEST(0, + COALESCE( + CASE + WHEN SUM(cs.day_count) >= 4 THEN AVG(cs.daily_revenue) + ELSE ct.overall_avg_revenue + END * + (1 + COALESCE(sf.seasonality_factor, 0)), + 0 + ) + ) as forecast_revenue, + CASE + WHEN ct.total_days >= 60 THEN 90 + WHEN ct.total_days >= 30 THEN 80 + WHEN ct.total_days >= 14 THEN 70 + ELSE 60 + END as confidence_level, + NOW() as created_at + FROM temp_category_sales cs + JOIN temp_category_stats ct ON cs.cat_id = ct.cat_id + CROSS JOIN temp_forecast_dates fd + LEFT JOIN sales_seasonality sf ON fd.month = sf.month + GROUP BY + cs.cat_id, + fd.forecast_date, + ct.overall_avg_revenue, + ct.total_days, + sf.seasonality_factor, + sf.month + HAVING AVG(cs.daily_quantity) > 0 + ON CONFLICT (category_id, forecast_date) DO UPDATE + SET + forecast_units = EXCLUDED.forecast_units, + forecast_revenue = EXCLUDED.forecast_revenue, + confidence_level = EXCLUDED.confidence_level, + created_at = NOW() + `); + + // Clean up temporary tables + await connection.query(` + DROP TABLE IF EXISTS temp_forecast_dates; + DROP TABLE IF EXISTS temp_daily_sales; + DROP TABLE IF EXISTS temp_product_stats; + DROP TABLE IF EXISTS temp_category_sales; + DROP TABLE IF EXISTS temp_category_stats; + `); + + processedCount = Math.floor(totalProducts * 1.0); + outputProgress({ + status: 'running', + operation: 'Category forecasts calculated and temporary tables cleaned up', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // If we get here, everything completed successfully + success = true; + + // Update calculate_status + await connection.query(` + INSERT INTO calculate_status (module_name, last_calculation_timestamp) + VALUES ('sales_forecasts', NOW()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = NOW() + `); + + return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + } catch (error) { + success = false; + logError(error, 'Error calculating sales forecasts'); + throw error; + } finally { + if (connection) { + try { + // Ensure temporary tables are cleaned up + await connection.query(` + DROP TABLE IF EXISTS temp_forecast_dates; + DROP TABLE IF EXISTS temp_daily_sales; + DROP TABLE IF EXISTS temp_product_stats; + DROP TABLE IF EXISTS temp_category_sales; + DROP TABLE IF EXISTS temp_category_stats; + `); + } catch (err) { + console.error('Error cleaning up temporary tables:', err); + } + connection.release(); + } + } +} + +module.exports = calculateSalesForecasts; \ No newline at end of file diff --git a/inventory-server/old/metrics/time-aggregates.js b/inventory-server/old/metrics/time-aggregates.js new file mode 100644 index 0000000..aeb0179 --- /dev/null +++ b/inventory-server/old/metrics/time-aggregates.js @@ -0,0 +1,344 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); +const { getConnection } = require('./utils/db'); + +async function calculateTimeAggregates(startTime, totalProducts, processedCount = 0, isCancelled = false) { + const connection = await getConnection(); + let success = false; + let processedOrders = 0; + + try { + if (isCancelled) { + outputProgress({ + status: 'cancelled', + operation: 'Time aggregates calculation cancelled', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: null, + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + return { + processedProducts: processedCount, + processedOrders: 0, + processedPurchaseOrders: 0, + success + }; + } + + // Get order count that will be processed + const orderCount = await connection.query(` + SELECT COUNT(*) as count + FROM orders o + WHERE o.canceled = false + `); + processedOrders = parseInt(orderCount.rows[0].count); + + outputProgress({ + status: 'running', + operation: 'Starting time aggregates calculation', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Create a temporary table for end-of-month inventory values + await connection.query(` + CREATE TEMPORARY TABLE IF NOT EXISTS temp_monthly_inventory AS + WITH months AS ( + -- Generate all year/month combinations for the last 12 months + SELECT + EXTRACT(YEAR FROM month_date)::INTEGER as year, + EXTRACT(MONTH FROM month_date)::INTEGER as month, + month_date as start_date, + (month_date + INTERVAL '1 month'::interval - INTERVAL '1 day'::interval)::DATE as end_date + FROM ( + SELECT generate_series( + DATE_TRUNC('month', CURRENT_DATE - INTERVAL '12 months'::interval)::DATE, + DATE_TRUNC('month', CURRENT_DATE)::DATE, + INTERVAL '1 month'::interval + ) as month_date + ) dates + ), + monthly_inventory_calc AS ( + SELECT + p.pid, + m.year, + m.month, + m.end_date, + p.stock_quantity as current_quantity, + -- Calculate sold during period (before end_date) + COALESCE(SUM( + CASE + WHEN o.date <= m.end_date THEN o.quantity + ELSE 0 + END + ), 0) as sold_after_end_date, + -- Calculate received during period (before end_date) + COALESCE(SUM( + CASE + WHEN po.received_date <= m.end_date THEN po.received + ELSE 0 + END + ), 0) as received_after_end_date, + p.cost_price + FROM + products p + CROSS JOIN + months m + LEFT JOIN + orders o ON p.pid = o.pid + AND o.canceled = false + AND o.date > m.end_date + AND o.date <= CURRENT_DATE + LEFT JOIN + purchase_orders po ON p.pid = po.pid + AND po.received_date IS NOT NULL + AND po.received_date > m.end_date + AND po.received_date <= CURRENT_DATE + GROUP BY + p.pid, m.year, m.month, m.end_date, p.stock_quantity, p.cost_price + ) + SELECT + pid, + year, + month, + -- End of month quantity = current quantity - sold after + received after + GREATEST(0, current_quantity - sold_after_end_date + received_after_end_date) as end_of_month_quantity, + -- End of month inventory value + GREATEST(0, current_quantity - sold_after_end_date + received_after_end_date) * cost_price as end_of_month_value, + cost_price + FROM + monthly_inventory_calc + `); + + processedCount = Math.floor(totalProducts * 0.40); + outputProgress({ + status: 'running', + operation: 'Monthly inventory values calculated, processing time aggregates', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Initial insert of time-based aggregates + await connection.query(` + INSERT INTO product_time_aggregates ( + pid, + year, + month, + total_quantity_sold, + total_revenue, + total_cost, + order_count, + stock_received, + stock_ordered, + avg_price, + profit_margin, + inventory_value, + gmroi + ) + WITH monthly_sales AS ( + SELECT + o.pid, + EXTRACT(YEAR FROM o.date::timestamp with time zone)::INTEGER as year, + EXTRACT(MONTH FROM o.date::timestamp with time zone)::INTEGER as month, + SUM(o.quantity) as total_quantity_sold, + SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) as total_revenue, + SUM(COALESCE(o.costeach, 0) * o.quantity) as total_cost, + COUNT(DISTINCT o.order_number) as order_count, + AVG(o.price - COALESCE(o.discount, 0)) as avg_price, + CASE + WHEN SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) > 0 + THEN ((SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) - SUM(COALESCE(o.costeach, 0) * o.quantity)) + / SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100 + ELSE 0 + END as profit_margin, + COUNT(DISTINCT DATE(o.date)) as active_days + FROM orders o + JOIN products p ON o.pid = p.pid + WHERE o.canceled = false + GROUP BY o.pid, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone) + ), + monthly_stock AS ( + SELECT + pid, + EXTRACT(YEAR FROM date::timestamp with time zone)::INTEGER as year, + EXTRACT(MONTH FROM date::timestamp with time zone)::INTEGER as month, + SUM(received) as stock_received, + SUM(ordered) as stock_ordered + FROM purchase_orders + GROUP BY pid, EXTRACT(YEAR FROM date::timestamp with time zone), EXTRACT(MONTH FROM date::timestamp with time zone) + ) + SELECT + COALESCE(s.pid, ms.pid, mi.pid) as pid, + COALESCE(s.year, ms.year, mi.year) as year, + COALESCE(s.month, ms.month, mi.month) as month, + COALESCE(s.total_quantity_sold, 0)::INTEGER as total_quantity_sold, + COALESCE(s.total_revenue, 0)::DECIMAL(10,3) as total_revenue, + COALESCE(s.total_cost, 0)::DECIMAL(10,3) as total_cost, + COALESCE(s.order_count, 0)::INTEGER as order_count, + COALESCE(ms.stock_received, 0)::INTEGER as stock_received, + COALESCE(ms.stock_ordered, 0)::INTEGER as stock_ordered, + COALESCE(s.avg_price, 0)::DECIMAL(10,3) as avg_price, + COALESCE(s.profit_margin, 0)::DECIMAL(10,3) as profit_margin, + COALESCE(mi.end_of_month_value, 0)::DECIMAL(10,3) as inventory_value, + CASE + WHEN COALESCE(mi.end_of_month_value, 0) > 0 + THEN (COALESCE(s.total_revenue, 0) - COALESCE(s.total_cost, 0)) + / NULLIF(COALESCE(mi.end_of_month_value, 0), 0) + ELSE 0 + END::DECIMAL(10,3) as gmroi + FROM ( + SELECT * FROM monthly_sales s + UNION ALL + SELECT + pid, + year, + month, + 0 as total_quantity_sold, + 0 as total_revenue, + 0 as total_cost, + 0 as order_count, + NULL as avg_price, + 0 as profit_margin, + 0 as active_days + FROM monthly_stock ms + WHERE NOT EXISTS ( + SELECT 1 FROM monthly_sales s2 + WHERE s2.pid = ms.pid + AND s2.year = ms.year + AND s2.month = ms.month + ) + UNION ALL + SELECT + pid, + year, + month, + 0 as total_quantity_sold, + 0 as total_revenue, + 0 as total_cost, + 0 as order_count, + NULL as avg_price, + 0 as profit_margin, + 0 as active_days + FROM temp_monthly_inventory mi + WHERE NOT EXISTS ( + SELECT 1 FROM monthly_sales s3 + WHERE s3.pid = mi.pid + AND s3.year = mi.year + AND s3.month = mi.month + ) + AND NOT EXISTS ( + SELECT 1 FROM monthly_stock ms3 + WHERE ms3.pid = mi.pid + AND ms3.year = mi.year + AND ms3.month = mi.month + ) + ) s + LEFT JOIN monthly_stock ms + ON s.pid = ms.pid + AND s.year = ms.year + AND s.month = ms.month + LEFT JOIN temp_monthly_inventory mi + ON s.pid = mi.pid + AND s.year = mi.year + AND s.month = mi.month + ON CONFLICT (pid, year, month) DO UPDATE + SET + total_quantity_sold = EXCLUDED.total_quantity_sold, + total_revenue = EXCLUDED.total_revenue, + total_cost = EXCLUDED.total_cost, + order_count = EXCLUDED.order_count, + stock_received = EXCLUDED.stock_received, + stock_ordered = EXCLUDED.stock_ordered, + avg_price = EXCLUDED.avg_price, + profit_margin = EXCLUDED.profit_margin, + inventory_value = EXCLUDED.inventory_value, + gmroi = EXCLUDED.gmroi + `); + + processedCount = Math.floor(totalProducts * 0.60); + outputProgress({ + status: 'running', + operation: 'Base time aggregates calculated', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + // Clean up temporary tables + await connection.query('DROP TABLE IF EXISTS temp_monthly_inventory'); + + // If we get here, everything completed successfully + success = true; + + // Update calculate_status + await connection.query(` + INSERT INTO calculate_status (module_name, last_calculation_timestamp) + VALUES ('time_aggregates', NOW()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = NOW() + `); + + return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders: 0, + success + }; + + } catch (error) { + success = false; + logError(error, 'Error calculating time aggregates'); + throw error; + } finally { + if (connection) { + try { + // Ensure temporary tables are cleaned up + await connection.query('DROP TABLE IF EXISTS temp_monthly_inventory'); + } catch (err) { + console.error('Error cleaning up temporary tables:', err); + } + connection.release(); + } + } +} + +module.exports = calculateTimeAggregates; \ No newline at end of file diff --git a/inventory-server/old/metrics/utils/db.js b/inventory-server/old/metrics/utils/db.js new file mode 100644 index 0000000..6d4abef --- /dev/null +++ b/inventory-server/old/metrics/utils/db.js @@ -0,0 +1,39 @@ +const { Pool } = require('pg'); +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '../../..', '.env') }); + +// Database configuration +const dbConfig = { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432, + ssl: process.env.DB_SSL === 'true', + // Add performance optimizations + max: 10, // connection pool max size + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 60000 +}; + +// Create a single pool instance to be reused +const pool = new Pool(dbConfig); + +// Add event handlers for pool +pool.on('error', (err, client) => { + console.error('Unexpected error on idle client', err); +}); + +async function getConnection() { + return await pool.connect(); +} + +async function closePool() { + await pool.end(); +} + +module.exports = { + dbConfig, + getConnection, + closePool +}; \ No newline at end of file diff --git a/inventory-server/old/metrics/utils/progress.js b/inventory-server/old/metrics/utils/progress.js new file mode 100644 index 0000000..6b66ceb --- /dev/null +++ b/inventory-server/old/metrics/utils/progress.js @@ -0,0 +1,158 @@ +const fs = require('fs'); +const path = require('path'); + +// Helper function to format elapsed time +function formatElapsedTime(elapsed) { + // If elapsed is a timestamp, convert to elapsed milliseconds + if (elapsed instanceof Date || elapsed > 1000000000000) { + elapsed = Date.now() - elapsed; + } else { + // If elapsed is in seconds, convert to milliseconds + elapsed = elapsed * 1000; + } + + const seconds = Math.floor(elapsed / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } +} + +// Helper function to estimate remaining time +function estimateRemaining(startTime, current, total) { + if (current === 0) return null; + const elapsed = Date.now() - startTime; + const rate = current / elapsed; + const remaining = (total - current) / rate; + + const minutes = Math.floor(remaining / 60000); + const seconds = Math.floor((remaining % 60000) / 1000); + + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } else { + return `${seconds}s`; + } +} + +// Helper function to calculate rate +function calculateRate(startTime, current) { + const elapsed = (Date.now() - startTime) / 1000; // Convert to seconds + return elapsed > 0 ? Math.round(current / elapsed) : 0; +} + +// Set up logging +const LOG_DIR = path.join(__dirname, '../../../logs'); +const ERROR_LOG = path.join(LOG_DIR, 'import-errors.log'); +const IMPORT_LOG = path.join(LOG_DIR, 'import.log'); +const STATUS_FILE = path.join(LOG_DIR, 'metrics-status.json'); + +// Ensure log directory exists +if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); +} + +// Helper function to log errors +function logError(error, context = '') { + const timestamp = new Date().toISOString(); + const errorMessage = `[${timestamp}] ${context}\nError: ${error.message}\nStack: ${error.stack}\n\n`; + + // Log to error file + fs.appendFileSync(ERROR_LOG, errorMessage); + + // Also log to console + console.error(`\n${context}\nError: ${error.message}`); +} + +// Helper function to log import progress +function logImport(message) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${message}\n`; + fs.appendFileSync(IMPORT_LOG, logMessage); +} + +// Helper function to output progress +function outputProgress(data) { + // Save progress to file for resumption + saveProgress(data); + // Format as SSE event + const event = { + progress: data + }; + // Always send to stdout for frontend + process.stdout.write(JSON.stringify(event) + '\n'); + + // Log significant events to disk + const isSignificant = + // Operation starts + (data.operation && !data.current) || + // Operation completions and errors + data.status === 'complete' || + data.status === 'error' || + // Major phase changes + data.operation?.includes('Starting ABC classification') || + data.operation?.includes('Starting time-based aggregates') || + data.operation?.includes('Starting vendor metrics'); + + if (isSignificant) { + logImport(`${data.operation || 'Operation'}${data.message ? ': ' + data.message : ''}${data.error ? ' Error: ' + data.error : ''}${data.status ? ' Status: ' + data.status : ''}`); + } +} + +function saveProgress(progress) { + try { + fs.writeFileSync(STATUS_FILE, JSON.stringify({ + ...progress, + timestamp: Date.now() + })); + } catch (err) { + console.error('Failed to save progress:', err); + } +} + +function clearProgress() { + try { + if (fs.existsSync(STATUS_FILE)) { + fs.unlinkSync(STATUS_FILE); + } + } catch (err) { + console.error('Failed to clear progress:', err); + } +} + +function getProgress() { + try { + if (fs.existsSync(STATUS_FILE)) { + const progress = JSON.parse(fs.readFileSync(STATUS_FILE, 'utf8')); + // Check if the progress is still valid (less than 1 hour old) + if (progress.timestamp && Date.now() - progress.timestamp < 3600000) { + return progress; + } else { + // Clear old progress + clearProgress(); + } + } + } catch (err) { + console.error('Failed to read progress:', err); + clearProgress(); + } + return null; +} + +module.exports = { + formatElapsedTime, + estimateRemaining, + calculateRate, + logError, + logImport, + outputProgress, + saveProgress, + clearProgress, + getProgress +}; \ No newline at end of file diff --git a/inventory-server/old/metrics/vendor-metrics.js b/inventory-server/old/metrics/vendor-metrics.js new file mode 100644 index 0000000..b1aa08b --- /dev/null +++ b/inventory-server/old/metrics/vendor-metrics.js @@ -0,0 +1,378 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); +const { getConnection } = require('./utils/db'); + +async function calculateVendorMetrics(startTime, totalProducts, processedCount = 0, isCancelled = false) { + const connection = await getConnection(); + let success = false; + let processedOrders = 0; + let processedPurchaseOrders = 0; + + try { + if (isCancelled) { + outputProgress({ + status: 'cancelled', + operation: 'Vendor metrics calculation cancelled', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: null, + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders, + success + }; + } + + // Get counts of records that will be processed + const [orderCountResult, poCountResult] = await Promise.all([ + connection.query(` + SELECT COUNT(*) as count + FROM orders o + WHERE o.canceled = false + `), + connection.query(` + SELECT COUNT(*) as count + FROM purchase_orders po + WHERE po.status != 0 + `) + ]); + processedOrders = parseInt(orderCountResult.rows[0].count); + processedPurchaseOrders = parseInt(poCountResult.rows[0].count); + + outputProgress({ + status: 'running', + operation: 'Starting vendor metrics calculation', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // First ensure all vendors exist in vendor_details + await connection.query(` + INSERT INTO vendor_details (vendor, status, created_at, updated_at) + SELECT DISTINCT + vendor, + 'active' as status, + NOW() as created_at, + NOW() as updated_at + FROM products + WHERE vendor IS NOT NULL + ON CONFLICT (vendor) DO NOTHING + `); + + processedCount = Math.floor(totalProducts * 0.8); + outputProgress({ + status: 'running', + operation: 'Vendor details updated, calculating metrics', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders, + success + }; + + // Now calculate vendor metrics + await connection.query(` + INSERT INTO vendor_metrics ( + vendor, + total_revenue, + total_orders, + total_late_orders, + avg_lead_time_days, + on_time_delivery_rate, + order_fill_rate, + avg_order_value, + active_products, + total_products, + total_purchase_value, + avg_margin_percent, + status, + last_calculated_at + ) + WITH vendor_sales AS ( + SELECT + p.vendor, + SUM(o.quantity * o.price) as total_revenue, + COUNT(DISTINCT o.id) as total_orders, + COUNT(DISTINCT p.pid) as active_products, + SUM(o.quantity * (o.price - p.cost_price)) as total_margin + FROM products p + JOIN orders o ON p.pid = o.pid + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '12 months' + GROUP BY p.vendor + ), + vendor_po AS ( + SELECT + p.vendor, + COUNT(DISTINCT CASE WHEN po.receiving_status = 40 THEN po.id END) as received_orders, + COUNT(DISTINCT po.id) as total_orders, + AVG(CASE + WHEN po.receiving_status = 40 + AND po.received_date IS NOT NULL + AND po.date IS NOT NULL + THEN EXTRACT(EPOCH FROM (po.received_date::timestamp with time zone - po.date::timestamp with time zone)) / 86400.0 + ELSE NULL + END) as avg_lead_time_days, + SUM(po.ordered * po.po_cost_price) as total_purchase_value + FROM products p + JOIN purchase_orders po ON p.pid = po.pid + WHERE po.date >= CURRENT_DATE - INTERVAL '12 months' + GROUP BY p.vendor + ), + vendor_products AS ( + SELECT + vendor, + COUNT(DISTINCT pid) as total_products + FROM products + GROUP BY vendor + ) + SELECT + vs.vendor, + COALESCE(vs.total_revenue, 0) as total_revenue, + COALESCE(vp.total_orders, 0) as total_orders, + COALESCE(vp.total_orders - vp.received_orders, 0) as total_late_orders, + COALESCE(vp.avg_lead_time_days, 0) as avg_lead_time_days, + CASE + WHEN vp.total_orders > 0 + THEN (vp.received_orders / vp.total_orders) * 100 + ELSE 0 + END as on_time_delivery_rate, + CASE + WHEN vp.total_orders > 0 + THEN (vp.received_orders / vp.total_orders) * 100 + ELSE 0 + END as order_fill_rate, + CASE + WHEN vs.total_orders > 0 + THEN vs.total_revenue / vs.total_orders + ELSE 0 + END as avg_order_value, + COALESCE(vs.active_products, 0) as active_products, + COALESCE(vpr.total_products, 0) as total_products, + COALESCE(vp.total_purchase_value, 0) as total_purchase_value, + CASE + WHEN vs.total_revenue > 0 + THEN (vs.total_margin / vs.total_revenue) * 100 + ELSE 0 + END as avg_margin_percent, + 'active' as status, + NOW() as last_calculated_at + FROM vendor_sales vs + LEFT JOIN vendor_po vp ON vs.vendor = vp.vendor + LEFT JOIN vendor_products vpr ON vs.vendor = vpr.vendor + WHERE vs.vendor IS NOT NULL + ON CONFLICT (vendor) DO UPDATE + SET + total_revenue = EXCLUDED.total_revenue, + total_orders = EXCLUDED.total_orders, + total_late_orders = EXCLUDED.total_late_orders, + avg_lead_time_days = EXCLUDED.avg_lead_time_days, + on_time_delivery_rate = EXCLUDED.on_time_delivery_rate, + order_fill_rate = EXCLUDED.order_fill_rate, + avg_order_value = EXCLUDED.avg_order_value, + active_products = EXCLUDED.active_products, + total_products = EXCLUDED.total_products, + total_purchase_value = EXCLUDED.total_purchase_value, + avg_margin_percent = EXCLUDED.avg_margin_percent, + status = EXCLUDED.status, + last_calculated_at = EXCLUDED.last_calculated_at + `); + + processedCount = Math.floor(totalProducts * 0.9); + outputProgress({ + status: 'running', + operation: 'Vendor metrics calculated, updating time-based metrics', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + if (isCancelled) return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders, + success + }; + + // Calculate time-based metrics + await connection.query(` + INSERT INTO vendor_time_metrics ( + vendor, + year, + month, + total_orders, + late_orders, + avg_lead_time_days, + total_purchase_value, + total_revenue, + avg_margin_percent + ) + WITH monthly_orders AS ( + SELECT + p.vendor, + EXTRACT(YEAR FROM o.date::timestamp with time zone) as year, + EXTRACT(MONTH FROM o.date::timestamp with time zone) as month, + COUNT(DISTINCT o.id) as total_orders, + SUM(o.quantity * o.price) as total_revenue, + SUM(o.quantity * (o.price - p.cost_price)) as total_margin + FROM products p + JOIN orders o ON p.pid = o.pid + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '12 months' + AND p.vendor IS NOT NULL + GROUP BY p.vendor, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone) + ), + monthly_po AS ( + SELECT + p.vendor, + EXTRACT(YEAR FROM po.date::timestamp with time zone) as year, + EXTRACT(MONTH FROM po.date::timestamp with time zone) as month, + COUNT(DISTINCT po.id) as total_po, + COUNT(DISTINCT CASE + WHEN po.receiving_status = 40 AND po.received_date > po.expected_date + THEN po.id + END) as late_orders, + AVG(CASE + WHEN po.receiving_status = 40 + AND po.received_date IS NOT NULL + AND po.date IS NOT NULL + THEN EXTRACT(EPOCH FROM (po.received_date::timestamp with time zone - po.date::timestamp with time zone)) / 86400.0 + ELSE NULL + END) as avg_lead_time_days, + SUM(po.ordered * po.po_cost_price) as total_purchase_value + FROM products p + JOIN purchase_orders po ON p.pid = po.pid + WHERE po.date >= CURRENT_DATE - INTERVAL '12 months' + AND p.vendor IS NOT NULL + GROUP BY p.vendor, EXTRACT(YEAR FROM po.date::timestamp with time zone), EXTRACT(MONTH FROM po.date::timestamp with time zone) + ) + SELECT + mo.vendor, + mo.year, + mo.month, + COALESCE(mp.total_po, 0) as total_orders, + COALESCE(mp.late_orders, 0) as late_orders, + COALESCE(mp.avg_lead_time_days, 0) as avg_lead_time_days, + COALESCE(mp.total_purchase_value, 0) as total_purchase_value, + mo.total_revenue, + CASE + WHEN mo.total_revenue > 0 + THEN (mo.total_margin / mo.total_revenue) * 100 + ELSE 0 + END as avg_margin_percent + FROM monthly_orders mo + LEFT JOIN monthly_po mp ON mo.vendor = mp.vendor + AND mo.year = mp.year + AND mo.month = mp.month + UNION + SELECT + mp.vendor, + mp.year, + mp.month, + mp.total_po as total_orders, + mp.late_orders, + mp.avg_lead_time_days, + mp.total_purchase_value, + 0 as total_revenue, + 0 as avg_margin_percent + FROM monthly_po mp + LEFT JOIN monthly_orders mo ON mp.vendor = mo.vendor + AND mp.year = mo.year + AND mp.month = mo.month + WHERE mo.vendor IS NULL + ON CONFLICT (vendor, year, month) DO UPDATE + SET + total_orders = EXCLUDED.total_orders, + late_orders = EXCLUDED.late_orders, + avg_lead_time_days = EXCLUDED.avg_lead_time_days, + total_purchase_value = EXCLUDED.total_purchase_value, + total_revenue = EXCLUDED.total_revenue, + avg_margin_percent = EXCLUDED.avg_margin_percent + `); + + processedCount = Math.floor(totalProducts * 0.95); + outputProgress({ + status: 'running', + operation: 'Time-based vendor metrics calculated', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // If we get here, everything completed successfully + success = true; + + // Update calculate_status + await connection.query(` + INSERT INTO calculate_status (module_name, last_calculation_timestamp) + VALUES ('vendor_metrics', NOW()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = NOW() + `); + + return { + processedProducts: processedCount, + processedOrders, + processedPurchaseOrders, + success + }; + + } catch (error) { + success = false; + logError(error, 'Error calculating vendor metrics'); + throw error; + } finally { + if (connection) { + connection.release(); + } + } +} + +module.exports = calculateVendorMetrics; \ No newline at end of file diff --git a/inventory-server/old/old_csv/import-csv.js b/inventory-server/old/old_csv/import-csv.js new file mode 100644 index 0000000..33fe6fa --- /dev/null +++ b/inventory-server/old/old_csv/import-csv.js @@ -0,0 +1,1049 @@ +const fs = require('fs'); +const path = require('path'); +const csv = require('csv-parse'); +const mysql = require('mysql2/promise'); +const dotenv = require('dotenv'); +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('../metrics/utils/progress'); + +// Get test limits from environment variables +const PRODUCTS_TEST_LIMIT = parseInt(process.env.PRODUCTS_TEST_LIMIT || '0'); +const ORDERS_TEST_LIMIT = parseInt(process.env.ORDERS_TEST_LIMIT || '10000'); +const PURCHASE_ORDERS_TEST_LIMIT = parseInt(process.env.PURCHASE_ORDERS_TEST_LIMIT || '10000'); + +dotenv.config({ path: path.join(__dirname, '../.env') }); + +const dbConfig = { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + multipleStatements: true, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + namedPlaceholders: true +}; + +// Set up logging +const LOG_DIR = path.join(__dirname, '../logs'); +const ERROR_LOG = path.join(LOG_DIR, 'import-errors.log'); +const IMPORT_LOG = path.join(LOG_DIR, 'import.log'); + +// Ensure log directory exists +if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); +} + +// Helper function to log errors +function logError(error, context = '') { + const timestamp = new Date().toISOString(); + const errorMessage = `[${timestamp}] ${context}\nError: ${error.message}\nStack: ${error.stack}\n\n`; + + // Log to error file + fs.appendFileSync(ERROR_LOG, errorMessage); + + // Also log to console + console.error(`\n${context}\nError: ${error.message}`); +} + +// Helper function to log import progress +function logImport(message, isSignificant = false) { + // Only write to disk if it's a significant event + if (isSignificant) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${message}\n`; + fs.appendFileSync(IMPORT_LOG, logMessage); + } +} + +// Helper function to format duration +function formatDuration(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + seconds = Math.floor(seconds % 60); + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); +} + +// Helper function to output progress +function outputProgress(data) { + // Always send to stdout for frontend + process.stdout.write(JSON.stringify(data) + '\n'); + + // Log significant events to disk + const isSignificant = + // Operation starts + (data.operation && !data.current) || + // Operation completions and errors + data.status === 'complete' || + data.status === 'error' || + // Test limits reached + data.message?.includes('test limit') || + // Schema changes + data.operation?.includes('Creating database schema') || + // Parallel import starts + data.message?.includes('Processing orders and purchase orders simultaneously'); + + if (isSignificant) { + logImport(`${data.operation || 'Operation'}${data.message ? ': ' + data.message : ''}${data.error ? ' Error: ' + data.error : ''}${data.status ? ' Status: ' + data.status : ''}`, true); + } +} + +// Helper function to count total rows in a CSV file +async function countRows(filePath) { + return new Promise((resolve, reject) => { + let count = 0; + fs.createReadStream(filePath) + .pipe(csv.parse()) + .on('data', () => count++) + .on('error', reject) + .on('end', () => resolve(count - 1)); // Subtract 1 for header row + }); +} + +// Helper function to update progress with time estimate +function updateProgress(current, total, operation, startTime, added = 0, updated = 0, skipped = 0) { + outputProgress({ + status: 'running', + operation, + current, + total, + rate: calculateRate(startTime, current), + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, current, total), + percentage: ((current / total) * 100).toFixed(1), + added, + updated, + skipped + }); +} + +// Helper function to handle category normalization +async function handleCategories(connection, productId, categoriesStr) { + if (!categoriesStr) { + // If no categories, remove all existing relationships + await connection.query( + 'DELETE FROM product_categories WHERE product_id = ?', + [productId] + ); + return; + } + + // Special cases that should not be split + const specialCategories = [ + 'Paint, Dyes & Chalk', + 'Fabric Paint, Markers, and Dye', + 'Crystals, Gems & Rhinestones', + 'Pens, Pencils & Markers' + ]; + + // Split categories and clean them, preserving special cases + const categories = []; + let remainingStr = categoriesStr; + + // First check for special categories + for (const special of specialCategories) { + if (remainingStr.includes(special)) { + categories.push(special); + // Remove the special category from the string + remainingStr = remainingStr.replace(special, ''); + } + } + + // Then process any remaining regular categories + remainingStr.split(',') + .map(cat => cat.trim()) + .filter(cat => cat.length > 0) + .forEach(cat => { + if (!categories.includes(cat)) { + categories.push(cat); + } + }); + + // Remove existing category relationships for this product + await connection.query( + 'DELETE FROM product_categories WHERE product_id = ?', + [productId] + ); + + // Insert categories and create relationships + for (const category of categories) { + // Insert category if it doesn't exist + await connection.query( + 'INSERT IGNORE INTO categories (name) VALUES (?)', + [category] + ); + + // Get category ID and create relationship in one query to avoid race conditions + await connection.query(` + INSERT IGNORE INTO product_categories (product_id, category_id) + SELECT ?, id FROM categories WHERE name = ?`, + [productId, category] + ); + } +} + +// Helper function to calculate sales velocity metrics +async function calculateSalesVelocity(connection, productId) { + const [rows] = await connection.query(` + SELECT + COALESCE(COUNT(*) / NULLIF(DATEDIFF(MAX(date), MIN(date)), 0), 0) as daily_sales_avg, + COALESCE(SUM(quantity) / NULLIF(DATEDIFF(MAX(date), MIN(date)), 0) * 7, 0) as weekly_sales_avg, + COALESCE(SUM(quantity) / NULLIF(DATEDIFF(MAX(date), MIN(date)), 0) * 30, 0) as monthly_sales_avg + FROM orders + WHERE product_id = ? AND canceled = false + GROUP BY product_id + `, [productId]); + + return rows[0] || { daily_sales_avg: 0, weekly_sales_avg: 0, monthly_sales_avg: 0 }; +} + +// Helper function to calculate stock metrics +async function calculateStockMetrics(connection, productId, dailySalesAvg) { + const [product] = await connection.query( + 'SELECT stock_quantity FROM products WHERE product_id = ?', + [productId] + ); + + if (!product[0]) return null; + + const stockQty = product[0].stock_quantity; + const daysOfInventory = dailySalesAvg > 0 ? Math.floor(stockQty / dailySalesAvg) : 999; + const weeksOfInventory = Math.floor(daysOfInventory / 7); + + // Calculate safety stock (2 weeks of average sales) + const safetyStock = Math.ceil(dailySalesAvg * 14); + + // Calculate reorder point (safety stock + 1 week of sales) + const reorderPoint = Math.ceil(safetyStock + (dailySalesAvg * 7)); + + return { + days_of_inventory: daysOfInventory, + weeks_of_inventory: weeksOfInventory, + safety_stock: safetyStock, + reorder_point: reorderPoint + }; +} + +// Helper function to calculate financial metrics +async function calculateFinancialMetrics(connection, productId) { + const [rows] = await connection.query(` + SELECT + SUM(o.price * o.quantity) as total_revenue, + AVG((o.price - p.cost_price) / o.price * 100) as avg_margin_percent + FROM orders o + JOIN products p ON o.product_id = p.product_id + WHERE o.product_id = ? AND o.canceled = false + GROUP BY o.product_id + `, [productId]); + + return rows[0] || { total_revenue: 0, avg_margin_percent: 0 }; +} + +// Helper function to calculate purchase metrics +async function calculatePurchaseMetrics(connection, productId) { + const [rows] = await connection.query(` + SELECT + AVG(DATEDIFF(received_date, date)) as avg_lead_time_days, + MAX(date) as last_purchase_date, + MAX(received_date) as last_received_date + FROM purchase_orders + WHERE product_id = ? AND status = 'closed' + GROUP BY product_id + `, [productId]); + + return rows[0] || { + avg_lead_time_days: 0, + last_purchase_date: null, + last_received_date: null + }; +} + +// Helper function to calculate ABC classification +async function calculateABCClass(connection, productId) { + // Get total revenue for this product + const [productRevenue] = await connection.query(` + SELECT SUM(price * quantity) as revenue + FROM orders + WHERE product_id = ? AND canceled = false + `, [productId]); + + // Get total revenue across all products + const [totalRevenue] = await connection.query(` + SELECT SUM(price * quantity) as total + FROM orders + WHERE canceled = false + `); + + const revenue = productRevenue[0]?.revenue || 0; + const total = totalRevenue[0]?.total || 0; + + if (total === 0) return 'C'; + + const percentage = (revenue / total) * 100; + + // A: Top 20% of revenue + // B: Next 30% of revenue + // C: Remaining 50% of revenue + if (percentage >= 20) return 'A'; + if (percentage >= 5) return 'B'; + return 'C'; +} + +// Helper function to calculate time-based aggregates +async function calculateTimeAggregates(connection, productId) { + await connection.query(` + INSERT INTO product_time_aggregates ( + product_id, year, month, + total_quantity_sold, total_revenue, total_cost, + order_count, stock_received, stock_ordered, + avg_price, profit_margin + ) + SELECT + o.product_id, + YEAR(o.date) as year, + MONTH(o.date) as month, + SUM(o.quantity) as total_quantity_sold, + SUM(o.price * o.quantity) as total_revenue, + SUM(p.cost_price * o.quantity) as total_cost, + COUNT(DISTINCT o.order_number) as order_count, + COALESCE(SUM(po.received), 0) as stock_received, + COALESCE(SUM(po.ordered), 0) as stock_ordered, + AVG(o.price) as avg_price, + CASE + WHEN SUM(o.price * o.quantity) = 0 THEN 0 + ELSE ((SUM(o.price * o.quantity) - COALESCE(SUM(p.cost_price * o.quantity), 0)) / + NULLIF(SUM(o.price * o.quantity), 0) * 100) + END as profit_margin + FROM orders o + JOIN products p ON o.product_id = p.product_id + LEFT JOIN purchase_orders po ON o.product_id = po.product_id + AND YEAR(o.date) = YEAR(po.date) + AND MONTH(o.date) = MONTH(po.date) + WHERE o.product_id = ? AND o.canceled = false + GROUP BY o.product_id, YEAR(o.date), MONTH(o.date) + ON DUPLICATE KEY UPDATE + total_quantity_sold = VALUES(total_quantity_sold), + total_revenue = VALUES(total_revenue), + total_cost = VALUES(total_cost), + order_count = VALUES(order_count), + stock_received = VALUES(stock_received), + stock_ordered = VALUES(stock_ordered), + avg_price = VALUES(avg_price), + profit_margin = VALUES(profit_margin) + `, [productId]); +} + +// Helper function to calculate vendor metrics +async function calculateVendorMetrics(connection) { + try { + // Get list of vendors + const [vendors] = await connection.query('SELECT DISTINCT vendor FROM products WHERE vendor IS NOT NULL'); + const startTime = Date.now(); + let current = 0; + const total = vendors.length; + + outputProgress({ + operation: 'Calculating vendor metrics', + current: 0, + total, + percentage: '0.0' + }); + + for (const { vendor } of vendors) { + // Calculate average lead time + const [leadTimeResult] = await connection.query(` + SELECT + AVG(DATEDIFF(received_date, date)) as avg_lead_time, + COUNT(*) as total_orders, + SUM(CASE WHEN ordered = received THEN 1 ELSE 0 END) as fulfilled_orders + FROM purchase_orders + WHERE vendor = ? AND status = 'closed' + GROUP BY vendor + `, [vendor]); + + const metrics = leadTimeResult[0] || { + avg_lead_time: 0, + total_orders: 0, + fulfilled_orders: 0 + }; + + // Calculate fill rate + const fillRate = metrics.total_orders > 0 ? + (metrics.fulfilled_orders / metrics.total_orders * 100) : 0; + + // Update vendor metrics + await connection.query(` + INSERT INTO vendor_metrics ( + vendor, + avg_lead_time_days, + total_orders, + fulfilled_orders, + fill_rate + ) VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + avg_lead_time_days = VALUES(avg_lead_time_days), + total_orders = VALUES(total_orders), + fulfilled_orders = VALUES(fulfilled_orders), + fill_rate = VALUES(fill_rate) + `, [ + vendor, + metrics.avg_lead_time || 0, + metrics.total_orders, + metrics.fulfilled_orders, + fillRate + ]); + + current++; + updateProgress(current, total, 'Calculating vendor metrics', startTime); + } + + outputProgress({ + status: 'complete', + operation: 'Vendor metrics calculation completed', + current: total, + total, + percentage: '100.0' + }); + } catch (error) { + logError(error, 'Error calculating vendor metrics'); + throw error; + } +} + +async function importProducts(pool, filePath) { + const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true })); + const totalRows = PRODUCTS_TEST_LIMIT > 0 ? Math.min(await countRows(filePath), PRODUCTS_TEST_LIMIT) : await countRows(filePath); + const startTime = Date.now(); + outputProgress({ + operation: 'Starting products import', + current: 0, + total: totalRows, + testLimit: PRODUCTS_TEST_LIMIT, + percentage: '0' + }); + + function convertDate(dateStr) { + if (!dateStr) { + // Default to current date for missing dates + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + const [day, month, year] = dateStr.split('-'); + return `${year}-${month}-${day}`; + } + + let updated = 0; + let added = 0; + let rowCount = 0; + let lastUpdate = Date.now(); + + // Batch processing variables + const BATCH_SIZE = 100; + let batch = []; + let categoryUpdates = new Map(); // Store category updates for batch processing + + // Get a connection from the pool that we'll reuse + const connection = await pool.getConnection(); + + try { + for await (const record of parser) { + if (PRODUCTS_TEST_LIMIT > 0 && rowCount >= PRODUCTS_TEST_LIMIT) { + // Process remaining batch + if (batch.length > 0) { + await processBatch(batch, categoryUpdates); + } + outputProgress({ + operation: 'Products import', + message: `Reached test limit of ${PRODUCTS_TEST_LIMIT.toLocaleString()} rows`, + current: rowCount, + total: totalRows + }); + break; + } + rowCount++; + + // Update progress every 100ms to avoid console flooding + const now = Date.now(); + if (now - lastUpdate > 100) { + updateProgress(rowCount, totalRows, 'Products import', startTime, added, updated, 0); + lastUpdate = now; + } + + // Add to batch + batch.push({ + product_id: record.product_id, + title: record.title, + SKU: record.SKU, + created_at: convertDate(record.created_at), + stock_quantity: parseInt(record.stock_quantity) || 0, + price: parseFloat(record.price) || 0, + regular_price: parseFloat(record.regular_price) || 0, + cost_price: parseFloat(record.cost_price) || null, + landing_cost_price: parseFloat(record.landing_cost_price) || null, + barcode: record.barcode, + updated_at: convertDate(record.updated_at), + visible: record.visible === '1', + managing_stock: record.managing_stock === '1', + replenishable: record.replenishable === '1', + vendor: record.vendor, + vendor_reference: record.vendor_reference, + permalink: record.permalink, + categories: record.categories, + image: record.image, + brand: record.brand, + options: record.options, + tags: record.tags, + moq: parseInt(record.moq) || 1, + uom: parseInt(record.uom) || 1 + }); + + if (record.categories) { + categoryUpdates.set(record.product_id, record.categories); + } + + // Process batch if it reaches BATCH_SIZE + if (batch.length >= BATCH_SIZE) { + await processBatch(batch, categoryUpdates); + batch = []; + categoryUpdates.clear(); + } + } + + // Process any remaining records in the final batch + if (batch.length > 0) { + await processBatch(batch, categoryUpdates); + } + + outputProgress({ + status: 'running', + operation: 'Products import completed', + current: rowCount, + total: totalRows, + added, + updated, + duration: formatDuration((Date.now() - startTime) / 1000), + percentage: '100' + }); + } catch (error) { + console.error('Error during products import:', error); + throw error; + } finally { + if (connection) { + connection.release(); + } + } + + // Helper function to process a batch of records + async function processBatch(records, categoryUpdates) { + if (records.length === 0) return; + + try { + await connection.beginTransaction(); + try { + // Create the batch insert/update query + const values = records.map(r => [ + r.product_id, r.title, r.SKU, r.created_at, r.stock_quantity, + r.price, r.regular_price, r.cost_price, r.landing_cost_price, + r.barcode, r.updated_at, r.visible, r.managing_stock, + r.replenishable, r.vendor, r.vendor_reference, r.permalink, + r.categories, r.image, r.brand, r.options, r.tags, r.moq, r.uom + ]); + + const placeholders = records.map(() => + '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).join(','); + + const sql = `INSERT INTO products VALUES ${placeholders} + ON DUPLICATE KEY UPDATE + title = VALUES(title), + stock_quantity = VALUES(stock_quantity), + price = VALUES(price), + regular_price = VALUES(regular_price), + cost_price = VALUES(cost_price), + landing_cost_price = VALUES(landing_cost_price), + barcode = VALUES(barcode), + updated_at = VALUES(updated_at), + visible = VALUES(visible), + managing_stock = VALUES(managing_stock), + replenishable = VALUES(replenishable), + vendor = VALUES(vendor), + vendor_reference = VALUES(vendor_reference), + permalink = VALUES(permalink), + categories = VALUES(categories), + image = VALUES(image), + brand = VALUES(brand), + options = VALUES(options), + tags = VALUES(tags), + moq = VALUES(moq), + uom = VALUES(uom)`; + + const [result] = await connection.query(sql, values.flat()); + + // Update stats + if (result.affectedRows > 0) { + const insertCount = result.affectedRows - result.changedRows; + const updateCount = result.changedRows; + added += insertCount; + updated += updateCount; + } + + // Process categories within the same transaction + for (const [productId, categories] of categoryUpdates) { + await handleCategories(connection, productId, categories); + } + + await connection.commit(); + } catch (error) { + await connection.rollback(); + logError(error, `Error processing batch of ${records.length} records`); + throw error; + } + } catch (error) { + logError(error, `Error in batch processing:\nFirst record: ${JSON.stringify(records[0])}`); + // Continue with next batch instead of failing completely + } + } +} + +async function importOrders(pool, filePath) { + const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true })); + const totalRows = ORDERS_TEST_LIMIT > 0 ? Math.min(await countRows(filePath), ORDERS_TEST_LIMIT) : await countRows(filePath); + const startTime = Date.now(); + outputProgress({ + operation: 'Starting orders import', + current: 0, + total: totalRows, + testLimit: ORDERS_TEST_LIMIT, + percentage: '0' + }); + + function convertDate(dateStr) { + if (!dateStr) { + // Default to current date for missing dates + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + const [day, month, year] = dateStr.split('-'); + return `${year}-${month}-${day}`; + } + + // First, get all valid product IDs + const connection = await pool.getConnection(); + let validProductIds; + try { + const [rows] = await connection.query('SELECT product_id FROM products'); + validProductIds = new Set(rows.map(row => row.product_id.toString())); + } finally { + connection.release(); + } + + let skipped = 0; + let updated = 0; + let added = 0; + let rowCount = 0; + let lastUpdate = Date.now(); + + // Batch processing variables + const BATCH_SIZE = 500; + let batch = []; + + for await (const record of parser) { + if (ORDERS_TEST_LIMIT > 0 && rowCount >= ORDERS_TEST_LIMIT) { + // Process remaining batch + if (batch.length > 0) { + await processBatch(batch); + } + outputProgress({ + operation: 'Orders import', + message: `Reached test limit of ${ORDERS_TEST_LIMIT.toLocaleString()} rows`, + current: rowCount, + total: totalRows + }); + break; + } + rowCount++; + + // Update progress every 100ms + const now = Date.now(); + if (now - lastUpdate > 100) { + updateProgress(rowCount, totalRows, 'Orders import', startTime, added, updated, skipped); + lastUpdate = now; + } + + if (!validProductIds.has(record.product_id)) { + skipped++; + continue; + } + + // Add to batch + batch.push({ + order_number: record.order_number, + product_id: record.product_id, + SKU: record.SKU, + date: convertDate(record.date), + price: parseFloat(record.price) || 0, + quantity: parseInt(record.quantity) || 0, + discount: parseFloat(record.discount) || 0, + tax: parseFloat(record.tax) || 0, + tax_included: record.tax_included === '1', + shipping: parseFloat(record.shipping) || 0, + customer: record.customer, + canceled: record.canceled === '1' + }); + + // Process batch if it reaches BATCH_SIZE + if (batch.length >= BATCH_SIZE) { + await processBatch(batch); + batch = []; + } + } + + // Process any remaining records in the final batch + if (batch.length > 0) { + await processBatch(batch); + } + + outputProgress({ + status: 'running', + operation: 'Orders import completed', + current: rowCount, + total: totalRows, + added, + updated, + skipped, + duration: formatDuration((Date.now() - startTime) / 1000), + percentage: '100' + }); + + // Helper function to process a batch of records + async function processBatch(records) { + if (records.length === 0) return; + + const connection = await pool.getConnection(); + try { + // Create the batch insert/update query + const values = records.map(r => [ + r.order_number, r.product_id, r.SKU, r.date, r.price, + r.quantity, r.discount, r.tax, r.tax_included, r.shipping, + r.customer, r.canceled + ]); + + const placeholders = records.map(() => + '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).join(','); + + const sql = `INSERT INTO orders (order_number, product_id, SKU, date, price, + quantity, discount, tax, tax_included, shipping, customer, canceled) + VALUES ${placeholders} + ON DUPLICATE KEY UPDATE + price = VALUES(price), + quantity = VALUES(quantity), + discount = VALUES(discount), + tax = VALUES(tax), + tax_included = VALUES(tax_included), + shipping = VALUES(shipping), + canceled = VALUES(canceled)`; + + const [result] = await connection.query(sql, values.flat()); + + // Update stats + if (result.affectedRows > 0) { + // For INSERT ... ON DUPLICATE KEY UPDATE: + // - If a row is inserted, affectedRows = 1 + // - If a row is updated, affectedRows = 2 + // So we can calculate: + // - Number of inserts = number of rows where affectedRows = 1 + // - Number of updates = number of rows where affectedRows = 2 + const insertCount = result.affectedRows - result.changedRows; + const updateCount = result.changedRows; + added += insertCount; + updated += updateCount; + } + } catch (error) { + console.error(`\nError processing batch:`, error.message); + // Continue with next batch instead of failing completely + skipped += records.length; + } finally { + connection.release(); + } + } +} + +async function importPurchaseOrders(pool, filePath) { + const parser = fs.createReadStream(filePath).pipe(csv.parse({ columns: true, trim: true })); + const totalRows = PURCHASE_ORDERS_TEST_LIMIT > 0 ? Math.min(await countRows(filePath), PURCHASE_ORDERS_TEST_LIMIT) : await countRows(filePath); + const startTime = Date.now(); + outputProgress({ + operation: 'Starting purchase orders import', + current: 0, + total: totalRows, + testLimit: PURCHASE_ORDERS_TEST_LIMIT, + percentage: '0' + }); + + function convertDate(dateStr) { + if (!dateStr) { + // Default to current date for missing dates + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + const [day, month, year] = dateStr.split('-'); + return `${year}-${month}-${day}`; + } + + // First, get all valid product IDs + const connection = await pool.getConnection(); + let validProductIds; + try { + const [rows] = await connection.query('SELECT product_id FROM products'); + validProductIds = new Set(rows.map(row => row.product_id.toString())); + } finally { + connection.release(); + } + + let skipped = 0; + let updated = 0; + let added = 0; + let rowCount = 0; + let lastUpdate = Date.now(); + + // Batch processing variables + const BATCH_SIZE = 500; + let batch = []; + + for await (const record of parser) { + if (PURCHASE_ORDERS_TEST_LIMIT > 0 && rowCount >= PURCHASE_ORDERS_TEST_LIMIT) { + // Process remaining batch + if (batch.length > 0) { + await processBatch(batch); + } + outputProgress({ + operation: 'Purchase orders import', + message: `Reached test limit of ${PURCHASE_ORDERS_TEST_LIMIT.toLocaleString()} rows`, + current: rowCount, + total: totalRows + }); + break; + } + rowCount++; + + // Update progress every 100ms + const now = Date.now(); + if (now - lastUpdate > 100) { + updateProgress(rowCount, totalRows, 'Purchase orders import', startTime, added, updated, skipped); + lastUpdate = now; + } + + if (!validProductIds.has(record.product_id)) { + skipped++; + continue; + } + + // Add to batch + batch.push({ + po_id: record.po_id, + vendor: record.vendor, + date: convertDate(record.date), + expected_date: convertDate(record.expected_date), + product_id: record.product_id, + sku: record.sku, + cost_price: parseFloat(record.cost_price) || 0, + status: record.status || 'pending', + notes: record.notes, + ordered: parseInt(record.ordered) || 0, + received: parseInt(record.received) || 0, + received_date: convertDate(record.received_date) + }); + + // Process batch if it reaches BATCH_SIZE + if (batch.length >= BATCH_SIZE) { + await processBatch(batch); + batch = []; + } + } + + // Process any remaining records in the final batch + if (batch.length > 0) { + await processBatch(batch); + } + + outputProgress({ + status: 'running', + operation: 'Purchase orders import completed', + current: rowCount, + total: totalRows, + added, + updated, + skipped, + duration: formatDuration((Date.now() - startTime) / 1000), + percentage: '100' + }); + + // Helper function to process a batch of records + async function processBatch(records) { + if (records.length === 0) return; + + const connection = await pool.getConnection(); + try { + // Create the batch insert/update query + const values = records.map(r => [ + r.po_id, r.vendor, r.date, r.expected_date, r.product_id, + r.sku, r.cost_price, r.status, r.notes, r.ordered, + r.received, r.received_date + ]); + + const placeholders = records.map(() => + '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).join(','); + + const sql = `INSERT INTO purchase_orders (po_id, vendor, date, expected_date, + product_id, sku, cost_price, status, notes, ordered, received, received_date) + VALUES ${placeholders} + ON DUPLICATE KEY UPDATE + vendor = VALUES(vendor), + expected_date = VALUES(expected_date), + cost_price = VALUES(cost_price), + status = VALUES(status), + notes = VALUES(notes), + ordered = VALUES(ordered), + received = VALUES(received), + received_date = VALUES(received_date)`; + + const [result] = await connection.query(sql, values.flat()); + + // Update stats + if (result.affectedRows > 0) { + // For INSERT ... ON DUPLICATE KEY UPDATE: + // - If a row is inserted, affectedRows = 1 + // - If a row is updated, affectedRows = 2 + // So we can calculate: + // - Number of inserts = number of rows where affectedRows = 1 + // - Number of updates = number of rows where affectedRows = 2 + const insertCount = result.affectedRows - result.changedRows; + const updateCount = result.changedRows; + added += insertCount; + updated += updateCount; + } + } catch (error) { + console.error(`\nError processing batch:`, error.message); + // Continue with next batch instead of failing completely + skipped += records.length; + } finally { + connection.release(); + } + } +} + +async function main() { + const startTime = Date.now(); + let pool; + let importInProgress = false; + + try { + outputProgress({ + operation: 'Starting import process', + message: 'Creating connection pool...' + }); + + pool = mysql.createPool(dbConfig); + + // Check if tables exist, if not create them + outputProgress({ + operation: 'Checking database schema', + message: 'Verifying tables exist...' + }); + + const connection = await pool.getConnection(); + try { + // Check if products table exists as a proxy for schema being initialized + const [tables] = await connection.query( + 'SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = ? AND table_name = ?', + [dbConfig.database, 'products'] + ); + + if (tables[0].count === 0) { + outputProgress({ + operation: 'Creating database schema', + message: 'Tables not found, creating schema...' + }); + + const schemaSQL = fs.readFileSync(path.join(__dirname, '../db/schema.sql'), 'utf8'); + await connection.query(schemaSQL); + } + } finally { + connection.release(); + } + + // Import all data + try { + importInProgress = true; + + // Import products first since they're referenced by other tables + await importProducts(pool, path.join(__dirname, '../csv/39f2x83-products.csv')); + + // Process orders and purchase orders in parallel + outputProgress({ + operation: 'Starting parallel import', + message: 'Processing orders and purchase orders simultaneously...' + }); + + await Promise.all([ + importOrders(pool, path.join(__dirname, '../csv/39f2x83-orders.csv')), + importPurchaseOrders(pool, path.join(__dirname, '../csv/39f2x83-purchase_orders.csv')) + ]); + + // Only output completion if we haven't encountered an error + if (importInProgress) { + outputProgress({ + status: 'complete', + operation: 'Import process completed', + duration: formatDuration((Date.now() - startTime) / 1000) + }); + } + + } catch (error) { + importInProgress = false; + logError(error, 'Error during import'); + outputProgress({ + status: 'error', + operation: 'Import process', + error: error.message + }); + throw error; + } + } catch (error) { + importInProgress = false; + logError(error, 'Fatal error during import process'); + outputProgress({ + status: 'error', + operation: 'Import process', + error: error.message + }); + process.exit(1); + } finally { + if (pool) { + await pool.end(); + } + } +} + +// Run the import +main().catch(error => { + logError(error, 'Unhandled error in main process'); + process.exit(1); +}); \ No newline at end of file diff --git a/inventory-server/old/old_csv/update-csv.js b/inventory-server/old/old_csv/update-csv.js new file mode 100644 index 0000000..4f49fcb --- /dev/null +++ b/inventory-server/old/old_csv/update-csv.js @@ -0,0 +1,180 @@ +const path = require('path'); +const fs = require('fs'); +const axios = require('axios'); +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('../metrics/utils/progress'); + +// Change working directory to script directory +process.chdir(path.dirname(__filename)); + +require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') }); + +const FILES = [ + { + name: '39f2x83-products.csv', + url: process.env.PRODUCTS_CSV_URL + }, + { + name: '39f2x83-orders.csv', + url: process.env.ORDERS_CSV_URL + }, + { + name: '39f2x83-purchase_orders.csv', + url: process.env.PURCHASE_ORDERS_CSV_URL + } +]; + +let isCancelled = false; + +function cancelUpdate() { + isCancelled = true; + outputProgress({ + status: 'cancelled', + operation: 'CSV update cancelled', + current: 0, + total: FILES.length, + elapsed: null, + remaining: null, + rate: 0 + }); +} + +async function downloadFile(file, index, startTime) { + if (isCancelled) return; + + const csvDir = path.join(__dirname, '../csv'); + if (!fs.existsSync(csvDir)) { + fs.mkdirSync(csvDir, { recursive: true }); + } + + const writer = fs.createWriteStream(path.join(csvDir, file.name)); + + try { + const response = await axios({ + url: file.url, + method: 'GET', + responseType: 'stream' + }); + + const totalLength = response.headers['content-length']; + let downloadedLength = 0; + let lastProgressUpdate = Date.now(); + const PROGRESS_INTERVAL = 1000; // Update progress every second + + response.data.on('data', (chunk) => { + if (isCancelled) { + writer.end(); + return; + } + + downloadedLength += chunk.length; + + // Update progress based on time interval + const now = Date.now(); + if (now - lastProgressUpdate >= PROGRESS_INTERVAL) { + const progress = (downloadedLength / totalLength) * 100; + outputProgress({ + status: 'running', + operation: `Downloading ${file.name}`, + current: index + (downloadedLength / totalLength), + total: FILES.length, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, index + (downloadedLength / totalLength), FILES.length), + rate: calculateRate(startTime, index + (downloadedLength / totalLength)), + percentage: progress.toFixed(1), + file_progress: { + name: file.name, + downloaded: downloadedLength, + total: totalLength, + percentage: progress.toFixed(1) + } + }); + lastProgressUpdate = now; + } + }); + + response.data.pipe(writer); + + return new Promise((resolve, reject) => { + writer.on('finish', resolve); + writer.on('error', reject); + }); + } catch (error) { + fs.unlinkSync(path.join(csvDir, file.name)); + throw error; + } +} + +// Main function to update all files +async function updateFiles() { + const startTime = Date.now(); + + outputProgress({ + status: 'running', + operation: 'Starting CSV update', + current: 0, + total: FILES.length, + elapsed: '0s', + remaining: null, + rate: 0, + percentage: '0' + }); + + try { + for (let i = 0; i < FILES.length; i++) { + if (isCancelled) { + return; + } + + const file = FILES[i]; + await downloadFile(file, i, startTime); + + outputProgress({ + status: 'running', + operation: 'CSV update in progress', + current: i + 1, + total: FILES.length, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, i + 1, FILES.length), + rate: calculateRate(startTime, i + 1), + percentage: (((i + 1) / FILES.length) * 100).toFixed(1) + }); + } + + outputProgress({ + status: 'complete', + operation: 'CSV update complete', + current: FILES.length, + total: FILES.length, + elapsed: formatElapsedTime(startTime), + remaining: '0s', + rate: calculateRate(startTime, FILES.length), + percentage: '100' + }); + } catch (error) { + outputProgress({ + status: 'error', + operation: 'CSV update failed', + error: error.message, + current: 0, + total: FILES.length, + elapsed: formatElapsedTime(startTime), + remaining: null, + rate: 0 + }); + throw error; + } +} + +// Run the update only if this is the main module +if (require.main === module) { + updateFiles().catch((error) => { + console.error('Error updating CSV files:', error); + process.exit(1); + }); +} + +// Export the functions needed by the route +module.exports = { + updateFiles, + cancelUpdate +}; \ No newline at end of file diff --git a/inventory-server/old/populate-initial-metrics.js b/inventory-server/old/populate-initial-metrics.js new file mode 100644 index 0000000..379cbb5 --- /dev/null +++ b/inventory-server/old/populate-initial-metrics.js @@ -0,0 +1,677 @@ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); // For detecting CPU cores + +// Get the base directory (the directory containing the inventory-server folder) +const baseDir = path.resolve(__dirname, '../../..'); + +// Load environment variables from the inventory-server directory +require('dotenv').config({ path: path.resolve(__dirname, '../..', '.env') }); + +// Configure statement timeout (30 minutes) +const PG_STATEMENT_TIMEOUT_MS = 1800000; + +// Add error handler for uncaught exceptions +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + process.exit(1); +}); + +// Add error handler for unhandled promise rejections +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + process.exit(1); +}); + +// Load progress module +const progress = require('../scripts/metrics-new/utils/progress'); + +// Store progress functions in global scope to ensure availability +global.formatElapsedTime = progress.formatElapsedTime; +global.estimateRemaining = progress.estimateRemaining; +global.calculateRate = progress.calculateRate; +global.outputProgress = progress.outputProgress; +global.clearProgress = progress.clearProgress; +global.getProgress = progress.getProgress; +global.logError = progress.logError; + +// Load database module +const { getConnection, closePool } = require('../scripts/metrics-new/utils/db'); + +// Add cancel handler +let isCancelled = false; +let runningQueryPromise = null; + +function cancelCalculation() { + if (!isCancelled) { + isCancelled = true; + console.log('Calculation has been cancelled by user'); + + // Store the query promise to potentially cancel it + const queryToCancel = runningQueryPromise; + if (queryToCancel) { + console.log('Attempting to cancel the running query...'); + } + + // Force-terminate any query that's been running for more than 5 seconds + try { + const connection = getConnection(); + connection.then(async (conn) => { + try { + // Identify and terminate long-running queries from our application + await conn.query(` + SELECT pg_cancel_backend(pid) + FROM pg_stat_activity + WHERE query_start < now() - interval '5 seconds' + AND application_name = 'populate_metrics' + AND query NOT LIKE '%pg_cancel_backend%' + `); + + // Release connection + conn.release(); + } catch (err) { + console.error('Error during force cancellation:', err); + conn.release(); + } + }).catch(err => { + console.error('Could not get connection for cancellation:', err); + }); + } catch (err) { + console.error('Failed to terminate running queries:', err); + } + } + + return { + success: true, + message: 'Calculation has been cancelled' + }; +} + +// Handle SIGTERM signal for cancellation +process.on('SIGTERM', cancelCalculation); +process.on('SIGINT', cancelCalculation); + +const calculateInitialMetrics = (client, onProgress) => { + return client.query(` + -- Truncate the existing metrics tables to ensure clean data + TRUNCATE TABLE public.daily_product_snapshots; + TRUNCATE TABLE public.product_metrics; + + -- First let's create daily snapshots for all products with order activity + WITH SalesData AS ( + SELECT + p.pid, + p.sku, + o.date::date AS order_date, + -- Count orders to ensure we only include products with real activity + COUNT(o.id) as order_count, + -- Aggregate Sales (Quantity > 0, Status not Canceled/Returned) + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue, + + -- Aggregate Returns (Quantity < 0 or Status = Returned) + COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned, + COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue + FROM public.products p + LEFT JOIN public.orders o ON p.pid = o.pid + GROUP BY p.pid, p.sku, o.date::date + HAVING COUNT(o.id) > 0 -- Only include products with actual orders + ), + ReceivingData AS ( + SELECT + r.pid, + r.received_date::date AS receiving_date, + -- Count receiving documents to ensure we only include products with real activity + COUNT(DISTINCT r.receiving_id) as receiving_count, + -- Calculate received quantity for this day + SUM(r.received_quantity) AS units_received, + -- Calculate received cost for this day + SUM(r.received_quantity * r.unit_cost) AS cost_received + FROM public.receivings r + GROUP BY r.pid, r.received_date::date + HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.received_quantity) > 0 + ), + -- Get current stock quantities + StockData AS ( + SELECT + p.pid, + p.stock_quantity, + COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price, + COALESCE(p.price, 0.00) as current_price, + COALESCE(p.regular_price, 0.00) as current_regular_price + FROM public.products p + ), + -- Combine sales and receiving dates to get all activity dates + DatePidCombos AS ( + SELECT DISTINCT pid, order_date AS activity_date FROM SalesData + UNION + SELECT DISTINCT pid, receiving_date FROM ReceivingData + ), + -- Insert daily snapshots for all product-date combinations + SnapshotInsert AS ( + INSERT INTO public.daily_product_snapshots ( + snapshot_date, + pid, + sku, + eod_stock_quantity, + eod_stock_cost, + eod_stock_retail, + eod_stock_gross, + stockout_flag, + units_sold, + units_returned, + gross_revenue, + discounts, + returns_revenue, + net_revenue, + cogs, + gross_regular_revenue, + profit, + units_received, + cost_received, + calculation_timestamp + ) + SELECT + d.activity_date AS snapshot_date, + d.pid, + p.sku, + -- Use current stock as approximation, since historical stock data is not available + s.stock_quantity AS eod_stock_quantity, + s.stock_quantity * s.effective_cost_price AS eod_stock_cost, + s.stock_quantity * s.current_price AS eod_stock_retail, + s.stock_quantity * s.current_regular_price AS eod_stock_gross, + (s.stock_quantity <= 0) AS stockout_flag, + -- Sales metrics + COALESCE(sd.units_sold, 0), + COALESCE(sd.units_returned, 0), + COALESCE(sd.gross_revenue_unadjusted, 0.00), + COALESCE(sd.discounts, 0.00), + COALESCE(sd.returns_revenue, 0.00), + COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue, + COALESCE(sd.cogs, 0.00), + COALESCE(sd.gross_regular_revenue, 0.00), + (COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, + -- Receiving metrics + COALESCE(rd.units_received, 0), + COALESCE(rd.cost_received, 0.00), + now() -- calculation timestamp + FROM DatePidCombos d + JOIN public.products p ON d.pid = p.pid + LEFT JOIN SalesData sd ON d.pid = sd.pid AND d.activity_date = sd.order_date + LEFT JOIN ReceivingData rd ON d.pid = rd.pid AND d.activity_date = rd.receiving_date + LEFT JOIN StockData s ON d.pid = s.pid + RETURNING pid, snapshot_date + ), + -- Now build the aggregated product metrics from the daily snapshots + MetricsInsert AS ( + INSERT INTO public.product_metrics ( + pid, + sku, + current_stock_quantity, + current_stock_cost, + current_stock_retail, + current_stock_msrp, + is_out_of_stock, + total_units_sold, + total_units_returned, + return_rate, + gross_revenue, + total_discounts, + total_returns, + net_revenue, + total_cogs, + total_gross_revenue, + total_profit, + profit_margin, + avg_daily_units, + reorder_point, + reorder_alert, + days_of_supply, + sales_velocity, + sales_velocity_score, + rank_by_revenue, + rank_by_quantity, + rank_by_profit, + total_received_quantity, + total_received_cost, + last_sold_date, + last_received_date, + days_since_last_sale, + days_since_last_received, + calculation_timestamp + ) + SELECT + p.pid, + p.sku, + p.stock_quantity AS current_stock_quantity, + p.stock_quantity * COALESCE(p.landing_cost_price, p.cost_price, 0) AS current_stock_cost, + p.stock_quantity * COALESCE(p.price, 0) AS current_stock_retail, + p.stock_quantity * COALESCE(p.regular_price, 0) AS current_stock_msrp, + (p.stock_quantity <= 0) AS is_out_of_stock, + -- Aggregate metrics + COALESCE(SUM(ds.units_sold), 0) AS total_units_sold, + COALESCE(SUM(ds.units_returned), 0) AS total_units_returned, + CASE + WHEN COALESCE(SUM(ds.units_sold), 0) > 0 + THEN COALESCE(SUM(ds.units_returned), 0)::float / NULLIF(COALESCE(SUM(ds.units_sold), 0), 0) + ELSE 0 + END AS return_rate, + COALESCE(SUM(ds.gross_revenue), 0) AS gross_revenue, + COALESCE(SUM(ds.discounts), 0) AS total_discounts, + COALESCE(SUM(ds.returns_revenue), 0) AS total_returns, + COALESCE(SUM(ds.net_revenue), 0) AS net_revenue, + COALESCE(SUM(ds.cogs), 0) AS total_cogs, + COALESCE(SUM(ds.gross_regular_revenue), 0) AS total_gross_revenue, + COALESCE(SUM(ds.profit), 0) AS total_profit, + CASE + WHEN COALESCE(SUM(ds.net_revenue), 0) > 0 + THEN COALESCE(SUM(ds.profit), 0) / NULLIF(COALESCE(SUM(ds.net_revenue), 0), 0) + ELSE 0 + END AS profit_margin, + -- Calculate average daily units + COALESCE(AVG(ds.units_sold), 0) AS avg_daily_units, + -- Calculate reorder point (simplified, can be enhanced with lead time and safety stock) + CEILING(COALESCE(AVG(ds.units_sold) * 14, 0)) AS reorder_point, + (p.stock_quantity <= CEILING(COALESCE(AVG(ds.units_sold) * 14, 0))) AS reorder_alert, + -- Days of supply based on average daily sales + CASE + WHEN COALESCE(AVG(ds.units_sold), 0) > 0 + THEN p.stock_quantity / NULLIF(COALESCE(AVG(ds.units_sold), 0), 0) + ELSE NULL + END AS days_of_supply, + -- Sales velocity (average units sold per day over last 30 days) + (SELECT COALESCE(AVG(recent.units_sold), 0) + FROM public.daily_product_snapshots recent + WHERE recent.pid = p.pid + AND recent.snapshot_date >= CURRENT_DATE - INTERVAL '30 days' + ) AS sales_velocity, + -- Placeholder for sales velocity score (can be calculated based on velocity) + 0 AS sales_velocity_score, + -- Will be updated later by ranking procedure + 0 AS rank_by_revenue, + 0 AS rank_by_quantity, + 0 AS rank_by_profit, + -- Receiving data + COALESCE(SUM(ds.units_received), 0) AS total_received_quantity, + COALESCE(SUM(ds.cost_received), 0) AS total_received_cost, + -- Date metrics + (SELECT MAX(sd.snapshot_date) + FROM public.daily_product_snapshots sd + WHERE sd.pid = p.pid AND sd.units_sold > 0 + ) AS last_sold_date, + (SELECT MAX(rd.snapshot_date) + FROM public.daily_product_snapshots rd + WHERE rd.pid = p.pid AND rd.units_received > 0 + ) AS last_received_date, + -- Calculate days since last sale/received + CASE + WHEN (SELECT MAX(sd.snapshot_date) + FROM public.daily_product_snapshots sd + WHERE sd.pid = p.pid AND sd.units_sold > 0) IS NOT NULL + THEN (CURRENT_DATE - (SELECT MAX(sd.snapshot_date) + FROM public.daily_product_snapshots sd + WHERE sd.pid = p.pid AND sd.units_sold > 0))::integer + ELSE NULL + END AS days_since_last_sale, + CASE + WHEN (SELECT MAX(rd.snapshot_date) + FROM public.daily_product_snapshots rd + WHERE rd.pid = p.pid AND rd.units_received > 0) IS NOT NULL + THEN (CURRENT_DATE - (SELECT MAX(rd.snapshot_date) + FROM public.daily_product_snapshots rd + WHERE rd.pid = p.pid AND rd.units_received > 0))::integer + ELSE NULL + END AS days_since_last_received, + now() -- calculation timestamp + FROM public.products p + LEFT JOIN public.daily_product_snapshots ds ON p.pid = ds.pid + GROUP BY p.pid, p.sku, p.stock_quantity, p.landing_cost_price, p.cost_price, p.price, p.regular_price + ) + + -- Update the calculate_status table + INSERT INTO public.calculate_status (module_name, last_calculation_timestamp) + VALUES + ('daily_snapshots', now()), + ('product_metrics', now()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = now(); + + -- Finally, update the ranks for products + UPDATE public.product_metrics pm SET + rank_by_revenue = rev_ranks.rank + FROM ( + SELECT pid, RANK() OVER (ORDER BY net_revenue DESC) AS rank + FROM public.product_metrics + WHERE net_revenue > 0 + ) rev_ranks + WHERE pm.pid = rev_ranks.pid; + + UPDATE public.product_metrics pm SET + rank_by_quantity = qty_ranks.rank + FROM ( + SELECT pid, RANK() OVER (ORDER BY total_units_sold DESC) AS rank + FROM public.product_metrics + WHERE total_units_sold > 0 + ) qty_ranks + WHERE pm.pid = qty_ranks.pid; + + UPDATE public.product_metrics pm SET + rank_by_profit = profit_ranks.rank + FROM ( + SELECT pid, RANK() OVER (ORDER BY total_profit DESC) AS rank + FROM public.product_metrics + WHERE total_profit > 0 + ) profit_ranks + WHERE pm.pid = profit_ranks.pid; + + -- Return count of products with metrics + SELECT COUNT(*) AS product_count FROM public.product_metrics + `); +}; + +async function populateInitialMetrics() { + let connection; + const startTime = Date.now(); + let calculateHistoryId; + + try { + // Clean up any previously running calculations + connection = await getConnection({ + // Add performance-related settings + application_name: 'populate_metrics', + statement_timeout: PG_STATEMENT_TIMEOUT_MS, // 30 min timeout per statement + }); + + // Ensure the calculate_status table exists and has the correct structure + await connection.query(` + CREATE TABLE IF NOT EXISTS calculate_status ( + module_name TEXT PRIMARY KEY, + last_calculation_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + await connection.query(` + UPDATE calculate_history + SET + status = 'cancelled', + end_time = NOW(), + duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER, + error_message = 'Previous calculation was not completed properly' + WHERE status = 'running' AND additional_info->>'type' = 'populate_initial_metrics' + `); + + // Create history record for this calculation + const historyResult = await connection.query(` + INSERT INTO calculate_history ( + start_time, + status, + additional_info + ) VALUES ( + NOW(), + 'running', + jsonb_build_object( + 'type', 'populate_initial_metrics', + 'sql_file', 'populate_initial_product_metrics.sql' + ) + ) RETURNING id + `); + calculateHistoryId = historyResult.rows[0].id; + + // Initialize progress + global.outputProgress({ + status: 'running', + operation: 'Starting initial product metrics population', + current: 0, + total: 100, + elapsed: '0s', + remaining: 'Calculating... (this may take a while)', + rate: 0, + percentage: '0', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + }, + historyId: calculateHistoryId + }); + + // Prepare the database - analyze tables + global.outputProgress({ + status: 'running', + operation: 'Analyzing database tables for better query performance', + current: 2, + total: 100, + elapsed: global.formatElapsedTime(startTime), + remaining: 'Analyzing...', + rate: 0, + percentage: '2', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + }, + historyId: calculateHistoryId + }); + + // Enable better query planning and parallel operations + await connection.query(` + -- Analyze tables for better query planning + ANALYZE public.products; + ANALYZE public.purchase_orders; + ANALYZE public.daily_product_snapshots; + ANALYZE public.orders; + + -- Enable parallel operations + SET LOCAL enable_parallel_append = on; + SET LOCAL enable_parallel_hash = on; + SET LOCAL max_parallel_workers_per_gather = 4; + + -- Larger work memory for complex sorts/joins + SET LOCAL work_mem = '128MB'; + `).catch(err => { + // Non-fatal if analyze fails + console.warn('Failed to analyze tables (non-fatal):', err.message); + }); + + // Execute the SQL query + global.outputProgress({ + status: 'running', + operation: 'Executing initial metrics SQL query', + current: 5, + total: 100, + elapsed: global.formatElapsedTime(startTime), + remaining: 'Calculating... (this could take several hours with 150M+ records)', + rate: 0, + percentage: '5', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + }, + historyId: calculateHistoryId + }); + + // Read the SQL file + const sqlFilePath = path.resolve(__dirname, 'populate_initial_product_metrics.sql'); + console.log('Base directory:', baseDir); + console.log('Script directory:', __dirname); + console.log('SQL file path:', sqlFilePath); + console.log('Current working directory:', process.cwd()); + + if (!fs.existsSync(sqlFilePath)) { + throw new Error(`SQL file not found at ${sqlFilePath}`); + } + + // Read and clean up the SQL (Slightly more robust cleaning) + const sqlQuery = fs.readFileSync(sqlFilePath, 'utf8') + .replace(/\r\n/g, '\n') // Handle Windows endings + .replace(/\r/g, '\n') // Handle old Mac endings + .trim(); // Remove leading/trailing whitespace VERY IMPORTANT + + // Log details again AFTER cleaning + console.log('SQL Query length (cleaned):', sqlQuery.length); + console.log('SQL Query structure validation:'); + console.log('- Contains DO block:', sqlQuery.includes('DO $$') || sqlQuery.includes('DO $')); // Check both types of tag start + console.log('- Contains BEGIN:', sqlQuery.includes('BEGIN')); + console.log('- Contains END:', sqlQuery.includes('END $$;') || sqlQuery.includes('END $')); // Check both types of tag end + console.log('- First 50 chars:', JSON.stringify(sqlQuery.slice(0, 50))); + console.log('- Last 100 chars (cleaned):', JSON.stringify(sqlQuery.slice(-100))); + + // Final check to ensure clean SQL ending + if (!sqlQuery.endsWith('END $$;')) { + console.warn('WARNING: SQL does not end with "END $$;". This might cause issues.'); + console.log('Exact ending:', JSON.stringify(sqlQuery.slice(-20))); + } + + // Execute the script + console.log('Starting initial product metrics population...'); + + // Track the query promise for potential cancellation + runningQueryPromise = connection.query({ + text: sqlQuery, + rowMode: 'array' + }); + await runningQueryPromise; + runningQueryPromise = null; + + // Update progress to 100% + global.outputProgress({ + status: 'complete', + operation: 'Initial product metrics population complete', + current: 100, + total: 100, + elapsed: global.formatElapsedTime(startTime), + remaining: '0s', + rate: 0, + percentage: '100', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + }, + historyId: calculateHistoryId + }); + + // Update history with completion + await connection.query(` + UPDATE calculate_history + SET + end_time = NOW(), + duration_seconds = $1, + status = 'completed' + WHERE id = $2 + `, [Math.round((Date.now() - startTime) / 1000), calculateHistoryId]); + + // Clear progress file on successful completion + global.clearProgress(); + + return { + success: true, + message: 'Initial product metrics population completed successfully', + duration: Math.round((Date.now() - startTime) / 1000) + }; + } catch (error) { + const endTime = Date.now(); + const totalElapsedSeconds = Math.round((endTime - startTime) / 1000); + + // Enhanced error logging + console.error('Error details:', { + message: error.message, + code: error.code, + hint: error.hint, + position: error.position, + detail: error.detail, + where: error.where ? error.where.substring(0, 500) + '...' : undefined, // Truncate to avoid huge logs + severity: error.severity, + file: error.file, + line: error.line, + routine: error.routine + }); + + // Update history with error + if (connection && calculateHistoryId) { + await connection.query(` + UPDATE calculate_history + SET + end_time = NOW(), + duration_seconds = $1, + status = $2, + error_message = $3 + WHERE id = $4 + `, [ + totalElapsedSeconds, + isCancelled ? 'cancelled' : 'failed', + error.message, + calculateHistoryId + ]); + } + + if (isCancelled) { + global.outputProgress({ + status: 'cancelled', + operation: 'Calculation cancelled', + current: 50, + total: 100, + elapsed: global.formatElapsedTime(startTime), + remaining: null, + rate: 0, + percentage: '50', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: totalElapsedSeconds + }, + historyId: calculateHistoryId + }); + } else { + global.outputProgress({ + status: 'error', + operation: 'Error during initial product metrics population', + message: error.message, + current: 0, + total: 100, + elapsed: global.formatElapsedTime(startTime), + remaining: null, + rate: 0, + percentage: '0', + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: totalElapsedSeconds + }, + historyId: calculateHistoryId + }); + } + + console.error('Error during initial product metrics population:', error); + return { + success: false, + error: error.message, + duration: totalElapsedSeconds + }; + } finally { + if (connection) { + connection.release(); + } + await closePool(); + } +} + +// Start population process +populateInitialMetrics() + .then(result => { + if (result.success) { + console.log(`Initial product metrics population completed successfully in ${result.duration} seconds`); + process.exit(0); + } else { + console.error(`Initial product metrics population failed: ${result.error}`); + process.exit(1); + } + }) + .catch(err => { + console.error('Unexpected error:', err); + process.exit(1); + }); \ No newline at end of file diff --git a/inventory-server/old/psql-csv-import.sh b/inventory-server/old/psql-csv-import.sh new file mode 100644 index 0000000..e616011 --- /dev/null +++ b/inventory-server/old/psql-csv-import.sh @@ -0,0 +1,428 @@ +#!/bin/bash + +# Simple script to import CSV to PostgreSQL using psql +# Usage: ./psql-csv-import.sh [start-batch] + +# Exit on error +set -e + +# Get arguments +CSV_FILE=$1 +TABLE_NAME=$2 +BATCH_SIZE=500000 # Process 500,000 rows at a time +START_BATCH=${3:-1} # Optional third parameter to start from a specific batch + +if [ -z "$CSV_FILE" ] || [ -z "$TABLE_NAME" ]; then + echo "Usage: ./psql-csv-import.sh [start-batch]" + exit 1 +fi + +# Check if file exists (only needed for batch 1) +if [ "$START_BATCH" -eq 1 ] && [ ! -f "$CSV_FILE" ]; then + echo "Error: CSV file '$CSV_FILE' not found" + exit 1 +fi + +# Load environment variables +if [ -f "../.env" ]; then + source "../.env" +else + echo "Warning: .env file not found, using default connection parameters" +fi + +# Set default connection parameters if not from .env +DB_HOST=${DB_HOST:-localhost} +DB_PORT=${DB_PORT:-5432} +DB_NAME=${DB_NAME:-inventory_db} +DB_USER=${DB_USER:-postgres} +export PGPASSWORD=${DB_PASSWORD:-} # Export password for psql + +# Common psql parameters +PSQL_OPTS="-h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME" + +# Function to clean up database state +cleanup_and_optimize() { + echo "Cleaning up and optimizing database state..." + + # Analyze the target table to update statistics + psql $PSQL_OPTS -c "ANALYZE $TABLE_NAME;" + + # Perform vacuum to reclaim space and update stats + psql $PSQL_OPTS -c "VACUUM $TABLE_NAME;" + + # Reset connection pool + psql $PSQL_OPTS -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid();" + + # Clean up shared memory + psql $PSQL_OPTS -c "DISCARD ALL;" + + echo "Optimization complete." +} + +# Show connection info +echo "Importing $CSV_FILE into $TABLE_NAME" +echo "Database: $DB_NAME on $DB_HOST:$DB_PORT with batch size: $BATCH_SIZE starting at batch $START_BATCH" + +# Start timer +START_TIME=$(date +%s) + +# Create progress tracking file +PROGRESS_FILE="/tmp/import_progress_${TABLE_NAME}.txt" +touch "$PROGRESS_FILE" +echo "Starting import at $(date), batch $START_BATCH" >> "$PROGRESS_FILE" + +# If we're resuming, run cleanup first +if [ "$START_BATCH" -gt 1 ]; then + cleanup_and_optimize +fi + +# For imported_product_stat_history, use optimized approach with hardcoded column names +if [ "$TABLE_NAME" = "imported_product_stat_history" ]; then + echo "Using optimized import for $TABLE_NAME" + + # Only drop constraints/indexes and create staging table for batch 1 + if [ "$START_BATCH" -eq 1 ]; then + # Extract CSV header + CSV_HEADER=$(head -n 1 "$CSV_FILE") + echo "CSV header: $CSV_HEADER" + + # Step 1: Drop constraints and indexes + echo "Dropping constraints and indexes..." + psql $PSQL_OPTS -c " + DO \$\$ + DECLARE + constraint_name TEXT; + BEGIN + -- Drop primary key constraint if exists + SELECT conname INTO constraint_name + FROM pg_constraint + WHERE conrelid = '$TABLE_NAME'::regclass AND contype = 'p'; + + IF FOUND THEN + EXECUTE 'ALTER TABLE $TABLE_NAME DROP CONSTRAINT IF EXISTS ' || constraint_name; + RAISE NOTICE 'Dropped primary key constraint: %', constraint_name; + END IF; + END \$\$; + " + + # Drop all indexes on the table + psql $PSQL_OPTS -c " + DO \$\$ + DECLARE + index_name TEXT; + index_record RECORD; + BEGIN + FOR index_record IN + SELECT indexname + FROM pg_indexes + WHERE tablename = '$TABLE_NAME' + LOOP + EXECUTE 'DROP INDEX IF EXISTS ' || index_record.indexname; + RAISE NOTICE 'Dropped index: %', index_record.indexname; + END LOOP; + END \$\$; + " + + # Step 2: Set maintenance_work_mem and disable triggers + echo "Setting maintenance_work_mem and disabling triggers..." + psql $PSQL_OPTS -c " + SET maintenance_work_mem = '1GB'; + ALTER TABLE $TABLE_NAME DISABLE TRIGGER ALL; + " + + # Step 3: Create staging table + echo "Creating staging table..." + psql $PSQL_OPTS -c " + DROP TABLE IF EXISTS staging_import; + CREATE UNLOGGED TABLE staging_import ( + pid TEXT, + date TEXT, + score TEXT, + score2 TEXT, + qty_in_baskets TEXT, + qty_sold TEXT, + notifies_set TEXT, + visibility_score TEXT, + health_score TEXT, + sold_view_score TEXT + ); + + -- Create an index on staging_import to improve OFFSET performance + CREATE INDEX ON staging_import (pid); + " + + # Step 4: Import CSV into staging table + echo "Importing CSV into staging table..." + psql $PSQL_OPTS -c "\copy staging_import FROM '$CSV_FILE' WITH CSV HEADER DELIMITER ','" + else + echo "Resuming import from batch $START_BATCH - skipping table creation and CSV import" + + # Check if staging table exists + STAGING_EXISTS=$(psql $PSQL_OPTS -t -c "SELECT EXISTS(SELECT 1 FROM pg_tables WHERE tablename='staging_import');" | tr -d '[:space:]') + + if [ "$STAGING_EXISTS" != "t" ]; then + echo "Error: Staging table 'staging_import' does not exist. Run without batch parameter first." + exit 1 + fi + + # Ensure triggers are disabled + psql $PSQL_OPTS -c "ALTER TABLE $TABLE_NAME DISABLE TRIGGER ALL;" + + # Optimize PostgreSQL for better performance + psql $PSQL_OPTS -c " + -- Increase work mem for this session + SET work_mem = '256MB'; + SET maintenance_work_mem = '1GB'; + " + fi + + # Step 5: Get total row count + TOTAL_ROWS=$(psql $PSQL_OPTS -t -c "SELECT COUNT(*) FROM staging_import;" | tr -d '[:space:]') + echo "Total rows to import: $TOTAL_ROWS" + + # Calculate starting point + PROCESSED=$(( ($START_BATCH - 1) * $BATCH_SIZE )) + if [ $PROCESSED -ge $TOTAL_ROWS ]; then + echo "Error: Start batch $START_BATCH is beyond the available rows ($TOTAL_ROWS)" + exit 1 + fi + + # Step 6: Process in batches with shell loop + BATCH_NUM=$(( $START_BATCH - 1 )) + + # We'll process batches in chunks of 10 before cleaning up + CHUNKS_SINCE_CLEANUP=0 + + while [ $PROCESSED -lt $TOTAL_ROWS ]; do + BATCH_NUM=$(( $BATCH_NUM + 1 )) + BATCH_START=$(date +%s) + MAX_ROWS=$(( $PROCESSED + $BATCH_SIZE )) + if [ $MAX_ROWS -gt $TOTAL_ROWS ]; then + MAX_ROWS=$TOTAL_ROWS + fi + + echo "Processing batch $BATCH_NUM (rows $PROCESSED to $MAX_ROWS)..." + + # Optimize query buffer for this batch + psql $PSQL_OPTS -c "SET work_mem = '256MB';" + + # Insert batch with type casts + psql $PSQL_OPTS -c " + INSERT INTO $TABLE_NAME ( + pid, date, score, score2, qty_in_baskets, qty_sold, + notifies_set, visibility_score, health_score, sold_view_score + ) + SELECT + pid::bigint, + date::date, + score::numeric, + score2::numeric, + qty_in_baskets::smallint, + qty_sold::smallint, + notifies_set::smallint, + visibility_score::numeric, + health_score::varchar, + sold_view_score::numeric + FROM staging_import + LIMIT $BATCH_SIZE + OFFSET $PROCESSED; + " + + # Update progress + BATCH_END=$(date +%s) + BATCH_ELAPSED=$(( $BATCH_END - $BATCH_START )) + PROGRESS_PCT=$(echo "scale=2; $MAX_ROWS * 100 / $TOTAL_ROWS" | bc) + + echo "Batch $BATCH_NUM committed in ${BATCH_ELAPSED}s, $MAX_ROWS of $TOTAL_ROWS rows processed ($PROGRESS_PCT%)" | tee -a "$PROGRESS_FILE" + + # Increment counter + PROCESSED=$(( $PROCESSED + $BATCH_SIZE )) + CHUNKS_SINCE_CLEANUP=$(( $CHUNKS_SINCE_CLEANUP + 1 )) + + # Check current row count every 10 batches + if [ $(( $BATCH_NUM % 10 )) -eq 0 ]; then + CURRENT_COUNT=$(psql $PSQL_OPTS -t -c "SELECT COUNT(*) FROM $TABLE_NAME;" | tr -d '[:space:]') + echo "Current row count in $TABLE_NAME: $CURRENT_COUNT" | tee -a "$PROGRESS_FILE" + + # Every 10 batches, run an intermediate cleanup + if [ $CHUNKS_SINCE_CLEANUP -ge 10 ]; then + echo "Running intermediate cleanup and optimization..." + psql $PSQL_OPTS -c "VACUUM $TABLE_NAME;" + CHUNKS_SINCE_CLEANUP=0 + fi + fi + + # Optional - write a checkpoint file to know where to restart + echo "$BATCH_NUM" > "/tmp/import_last_batch_${TABLE_NAME}.txt" + done + + # Only recreate indexes if we've completed the import + if [ $PROCESSED -ge $TOTAL_ROWS ]; then + # Step 7: Re-enable triggers and recreate primary key + echo "Re-enabling triggers and recreating primary key..." + psql $PSQL_OPTS -c " + ALTER TABLE $TABLE_NAME ENABLE TRIGGER ALL; + ALTER TABLE $TABLE_NAME ADD PRIMARY KEY (pid, date); + " + + # Step 8: Clean up and get final count + echo "Cleaning up and getting final count..." + psql $PSQL_OPTS -c " + DROP TABLE staging_import; + VACUUM ANALYZE $TABLE_NAME; + SELECT COUNT(*) AS \"Total rows in $TABLE_NAME\" FROM $TABLE_NAME; + " + else + echo "Import interrupted at batch $BATCH_NUM. To resume, run:" + echo "./psql-csv-import.sh $CSV_FILE $TABLE_NAME $BATCH_NUM" + fi + +else + # Generic approach for other tables + if [ "$START_BATCH" -eq 1 ]; then + # Extract CSV header + CSV_HEADER=$(head -n 1 "$CSV_FILE") + echo "CSV header: $CSV_HEADER" + + # Extract CSV header and format it for SQL + CSV_COLUMNS=$(echo "$CSV_HEADER" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//') + TEMP_COLUMNS=$(echo "$CSV_HEADER" | tr ',' '\n' | sed 's/$/ TEXT/' | tr '\n' ',' | sed 's/,$//') + + echo "Importing columns: $CSV_COLUMNS" + + # Step 1: Set maintenance_work_mem and disable triggers + echo "Setting maintenance_work_mem and disabling triggers..." + psql $PSQL_OPTS -c " + SET maintenance_work_mem = '1GB'; + ALTER TABLE $TABLE_NAME DISABLE TRIGGER ALL; + " + + # Step 2: Create temp table + echo "Creating temporary table..." + psql $PSQL_OPTS -c " + DROP TABLE IF EXISTS temp_import; + CREATE UNLOGGED TABLE temp_import ($TEMP_COLUMNS); + + -- Create an index on temp_import to improve OFFSET performance + CREATE INDEX ON temp_import ((1)); -- Index on first column + " + + # Step 3: Import CSV into temp table + echo "Importing CSV into temporary table..." + psql $PSQL_OPTS -c "\copy temp_import FROM '$CSV_FILE' WITH CSV HEADER DELIMITER ','" + else + echo "Resuming import from batch $START_BATCH - skipping table creation and CSV import" + + # Check if temp table exists + TEMP_EXISTS=$(psql $PSQL_OPTS -t -c "SELECT EXISTS(SELECT 1 FROM pg_tables WHERE tablename='temp_import');" | tr -d '[:space:]') + + if [ "$TEMP_EXISTS" != "t" ]; then + echo "Error: Temporary table 'temp_import' does not exist. Run without batch parameter first." + exit 1 + fi + + # Ensure triggers are disabled + psql $PSQL_OPTS -c "ALTER TABLE $TABLE_NAME DISABLE TRIGGER ALL;" + + # Optimize PostgreSQL for better performance + psql $PSQL_OPTS -c " + -- Increase work mem for this session + SET work_mem = '256MB'; + SET maintenance_work_mem = '1GB'; + " + + # Hard-code columns since we know them + CSV_COLUMNS='"pid","date","score","score2","qty_in_baskets","qty_sold","notifies_set","visibility_score","health_score","sold_view_score"' + + echo "Using standard columns: $CSV_COLUMNS" + fi + + # Step 4: Get total row count + TOTAL_ROWS=$(psql $PSQL_OPTS -t -c "SELECT COUNT(*) FROM temp_import;" | tr -d '[:space:]') + echo "Total rows to import: $TOTAL_ROWS" + + # Calculate starting point + PROCESSED=$(( ($START_BATCH - 1) * $BATCH_SIZE )) + if [ $PROCESSED -ge $TOTAL_ROWS ]; then + echo "Error: Start batch $START_BATCH is beyond the available rows ($TOTAL_ROWS)" + exit 1 + fi + + # Step 5: Process in batches with shell loop + BATCH_NUM=$(( $START_BATCH - 1 )) + + # We'll process batches in chunks of 10 before cleaning up + CHUNKS_SINCE_CLEANUP=0 + + while [ $PROCESSED -lt $TOTAL_ROWS ]; do + BATCH_NUM=$(( $BATCH_NUM + 1 )) + BATCH_START=$(date +%s) + MAX_ROWS=$(( $PROCESSED + $BATCH_SIZE )) + if [ $MAX_ROWS -gt $TOTAL_ROWS ]; then + MAX_ROWS=$TOTAL_ROWS + fi + + echo "Processing batch $BATCH_NUM (rows $PROCESSED to $MAX_ROWS)..." + + # Optimize query buffer for this batch + psql $PSQL_OPTS -c "SET work_mem = '256MB';" + + # Insert batch + psql $PSQL_OPTS -c " + INSERT INTO $TABLE_NAME ($CSV_COLUMNS) + SELECT $CSV_COLUMNS + FROM temp_import + LIMIT $BATCH_SIZE + OFFSET $PROCESSED; + " + + # Update progress + BATCH_END=$(date +%s) + BATCH_ELAPSED=$(( $BATCH_END - $BATCH_START )) + PROGRESS_PCT=$(echo "scale=2; $MAX_ROWS * 100 / $TOTAL_ROWS" | bc) + + echo "Batch $BATCH_NUM committed in ${BATCH_ELAPSED}s, $MAX_ROWS of $TOTAL_ROWS rows processed ($PROGRESS_PCT%)" | tee -a "$PROGRESS_FILE" + + # Increment counter + PROCESSED=$(( $PROCESSED + $BATCH_SIZE )) + CHUNKS_SINCE_CLEANUP=$(( $CHUNKS_SINCE_CLEANUP + 1 )) + + # Check current row count every 10 batches + if [ $(( $BATCH_NUM % 10 )) -eq 0 ]; then + CURRENT_COUNT=$(psql $PSQL_OPTS -t -c "SELECT COUNT(*) FROM $TABLE_NAME;" | tr -d '[:space:]') + echo "Current row count in $TABLE_NAME: $CURRENT_COUNT" | tee -a "$PROGRESS_FILE" + + # Every 10 batches, run an intermediate cleanup + if [ $CHUNKS_SINCE_CLEANUP -ge 10 ]; then + echo "Running intermediate cleanup and optimization..." + psql $PSQL_OPTS -c "VACUUM $TABLE_NAME;" + CHUNKS_SINCE_CLEANUP=0 + fi + fi + + # Optional - write a checkpoint file to know where to restart + echo "$BATCH_NUM" > "/tmp/import_last_batch_${TABLE_NAME}.txt" + done + + # Only clean up if we've completed the import + if [ $PROCESSED -ge $TOTAL_ROWS ]; then + # Step 6: Re-enable triggers and clean up + echo "Re-enabling triggers and cleaning up..." + psql $PSQL_OPTS -c " + ALTER TABLE $TABLE_NAME ENABLE TRIGGER ALL; + DROP TABLE temp_import; + VACUUM ANALYZE $TABLE_NAME; + SELECT COUNT(*) AS \"Total rows in $TABLE_NAME\" FROM $TABLE_NAME; + " + else + echo "Import interrupted at batch $BATCH_NUM. To resume, run:" + echo "./psql-csv-import.sh $CSV_FILE $TABLE_NAME $BATCH_NUM" + fi +fi + +# Calculate elapsed time +END_TIME=$(date +%s) +ELAPSED=$((END_TIME - START_TIME)) + +echo "Import completed successfully in ${ELAPSED}s ($(($ELAPSED / 60)) minutes)" +echo "Progress log saved to $PROGRESS_FILE" \ No newline at end of file diff --git a/inventory-server/old/reset-metrics.js b/inventory-server/old/reset-metrics.js new file mode 100644 index 0000000..0fc65bf --- /dev/null +++ b/inventory-server/old/reset-metrics.js @@ -0,0 +1,378 @@ +const { Client } = require('pg'); +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const dbConfig = { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432 +}; + +function outputProgress(data) { + if (!data.status) { + data = { + status: 'running', + ...data + }; + } + console.log(JSON.stringify(data)); +} + +// Explicitly define all metrics-related tables in dependency order +const METRICS_TABLES = [ + 'brand_metrics', + 'brand_time_metrics', + 'category_forecasts', + 'category_metrics', + 'category_sales_metrics', + 'category_time_metrics', + 'product_metrics', + 'product_time_aggregates', + 'sales_forecasts', + 'temp_purchase_metrics', + 'temp_sales_metrics', + 'vendor_metrics', + 'vendor_time_metrics', + 'vendor_details' +]; + +// Tables to always protect from being dropped +const PROTECTED_TABLES = [ + 'users', + 'permissions', + 'user_permissions', + 'calculate_history', + 'import_history', + 'ai_prompts', + 'ai_validation_performance', + 'templates', + 'reusable_images' +]; + +// Split SQL into individual statements +function splitSQLStatements(sql) { + sql = sql.replace(/\r\n/g, '\n'); + let statements = []; + let currentStatement = ''; + let inString = false; + let stringChar = ''; + + for (let i = 0; i < sql.length; i++) { + const char = sql[i]; + const nextChar = sql[i + 1] || ''; + + if ((char === "'" || char === '"') && sql[i - 1] !== '\\') { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + } + } + + if (!inString && char === '-' && nextChar === '-') { + while (i < sql.length && sql[i] !== '\n') i++; + continue; + } + + if (!inString && char === '/' && nextChar === '*') { + i += 2; + while (i < sql.length && (sql[i] !== '*' || sql[i + 1] !== '/')) i++; + i++; + continue; + } + + if (!inString && char === ';') { + if (currentStatement.trim()) { + statements.push(currentStatement.trim()); + } + currentStatement = ''; + } else { + currentStatement += char; + } + } + + if (currentStatement.trim()) { + statements.push(currentStatement.trim()); + } + + return statements; +} + +async function resetMetrics() { + let client; + try { + outputProgress({ + operation: 'Starting metrics reset', + message: 'Connecting to database...' + }); + + client = new Client(dbConfig); + await client.connect(); + + // Explicitly begin a transaction + await client.query('BEGIN'); + + // First verify current state + const initialTables = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = ANY($1) + AND tablename NOT IN (SELECT unnest($2::text[])) + `, [METRICS_TABLES, PROTECTED_TABLES]); + + outputProgress({ + operation: 'Initial state', + message: `Found ${initialTables.rows.length} existing metrics tables: ${initialTables.rows.map(t => t.name).join(', ')}` + }); + + // Disable foreign key checks at the start + await client.query('SET session_replication_role = \'replica\''); + + // Drop all metrics tables in reverse order to handle dependencies + outputProgress({ + operation: 'Dropping metrics tables', + message: 'Removing existing metrics tables...' + }); + + for (const table of [...METRICS_TABLES].reverse()) { + // Skip protected tables + if (PROTECTED_TABLES.includes(table)) { + outputProgress({ + operation: 'Protected table', + message: `Skipping protected table: ${table}` + }); + continue; + } + + try { + // Use NOWAIT to avoid hanging if there's a lock + await client.query(`DROP TABLE IF EXISTS "${table}" CASCADE`); + + // Verify the table was actually dropped + const checkDrop = await client.query(` + SELECT COUNT(*) as count + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = $1 + `, [table]); + + if (parseInt(checkDrop.rows[0].count) > 0) { + throw new Error(`Failed to drop table ${table} - table still exists`); + } + + outputProgress({ + operation: 'Table dropped', + message: `Successfully dropped table: ${table}` + }); + + // Commit after each table drop to ensure locks are released + await client.query('COMMIT'); + // Start a new transaction for the next table + await client.query('BEGIN'); + // Re-disable foreign key constraints for the new transaction + await client.query('SET session_replication_role = \'replica\''); + } catch (err) { + outputProgress({ + status: 'error', + operation: 'Drop table error', + message: `Error dropping table ${table}: ${err.message}` + }); + await client.query('ROLLBACK'); + // Re-start transaction for next table + await client.query('BEGIN'); + await client.query('SET session_replication_role = \'replica\''); + } + } + + // Verify all tables were dropped + const afterDrop = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = ANY($1) + `, [METRICS_TABLES]); + + if (afterDrop.rows.length > 0) { + throw new Error(`Failed to drop all tables. Remaining tables: ${afterDrop.rows.map(t => t.name).join(', ')}`); + } + + // Make sure we have a fresh transaction here + await client.query('COMMIT'); + await client.query('BEGIN'); + await client.query('SET session_replication_role = \'replica\''); + + // Read metrics schema + outputProgress({ + operation: 'Reading schema', + message: 'Loading metrics schema file...' + }); + + const schemaPath = path.resolve(__dirname, '../db/metrics-schema.sql'); + if (!fs.existsSync(schemaPath)) { + throw new Error(`Schema file not found at: ${schemaPath}`); + } + + const schemaSQL = fs.readFileSync(schemaPath, 'utf8'); + const statements = splitSQLStatements(schemaSQL); + + outputProgress({ + operation: 'Schema loaded', + message: `Found ${statements.length} SQL statements to execute` + }); + + // Execute schema statements + for (let i = 0; i < statements.length; i++) { + const stmt = statements[i]; + try { + const result = await client.query(stmt); + + // If this is a CREATE TABLE statement, verify the table was created + if (stmt.trim().toLowerCase().startsWith('create table')) { + const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?["]?(\w+)["]?/i)?.[1]; + if (tableName) { + const checkCreate = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = $1 + `, [tableName]); + + if (checkCreate.rows.length === 0) { + throw new Error(`Failed to create table ${tableName} - table does not exist after CREATE statement`); + } + + outputProgress({ + operation: 'Table created', + message: `Successfully created table: ${tableName}` + }); + } + } + + outputProgress({ + operation: 'SQL Progress', + message: { + statement: i + 1, + total: statements.length, + preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''), + rowCount: result.rowCount + } + }); + + // Commit every 10 statements to avoid long-running transactions + if (i > 0 && i % 10 === 0) { + await client.query('COMMIT'); + await client.query('BEGIN'); + await client.query('SET session_replication_role = \'replica\''); + } + } catch (sqlError) { + outputProgress({ + status: 'error', + operation: 'SQL Error', + message: { + error: sqlError.message, + statement: stmt, + statementNumber: i + 1 + } + }); + await client.query('ROLLBACK'); + throw sqlError; + } + } + + // Final commit for any pending statements + await client.query('COMMIT'); + + // Start new transaction for final checks + await client.query('BEGIN'); + + // Re-enable foreign key checks after all tables are created + await client.query('SET session_replication_role = \'origin\''); + + // Verify metrics tables were created + outputProgress({ + operation: 'Verifying metrics tables', + message: 'Checking all metrics tables were created...' + }); + + const metricsTablesResult = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = ANY($1) + `, [METRICS_TABLES]); + + outputProgress({ + operation: 'Tables found', + message: `Found ${metricsTablesResult.rows.length} tables: ${metricsTablesResult.rows.map(t => t.name).join(', ')}` + }); + + const existingMetricsTables = metricsTablesResult.rows.map(t => t.name); + const missingMetricsTables = METRICS_TABLES.filter(t => !existingMetricsTables.includes(t)); + + if (missingMetricsTables.length > 0) { + // Do one final check of the actual tables + const finalCheck = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + `); + outputProgress({ + operation: 'Final table check', + message: `All database tables: ${finalCheck.rows.map(t => t.name).join(', ')}` + }); + await client.query('ROLLBACK'); + throw new Error(`Failed to create metrics tables: ${missingMetricsTables.join(', ')}`); + } + + // Commit final transaction + await client.query('COMMIT'); + + outputProgress({ + status: 'complete', + operation: 'Reset complete', + message: 'All metrics tables have been reset successfully' + }); + } catch (error) { + outputProgress({ + status: 'error', + operation: 'Reset failed', + message: error.message, + stack: error.stack + }); + + if (client) { + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + console.error('Error during rollback:', rollbackError); + } + // Make sure to re-enable foreign key checks even if there's an error + await client.query('SET session_replication_role = \'origin\'').catch(() => {}); + } + throw error; + } finally { + if (client) { + // One final attempt to ensure foreign key checks are enabled + await client.query('SET session_replication_role = \'origin\'').catch(() => {}); + await client.end(); + } + } +} + +// Export if required as a module +if (typeof module !== 'undefined' && module.exports) { + module.exports = resetMetrics; +} + +// Run if called from command line +if (require.main === module) { + resetMetrics().catch(error => { + console.error('Error:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/inventory-server/old/scripts.js b/inventory-server/old/scripts.js new file mode 100644 index 0000000..fe188cd --- /dev/null +++ b/inventory-server/old/scripts.js @@ -0,0 +1,180 @@ +const readline = require('readline'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +const question = (query) => new Promise((resolve) => rl.question(query, resolve)); + +async function loadScript(name) { + try { + return await require(name); + } catch (error) { + console.error(`Failed to load script ${name}:`, error); + return null; + } +} + +async function runWithTimeout(fn) { + return new Promise((resolve, reject) => { + // Create a child process for the script + const child = require('child_process').fork(fn.toString(), [], { + stdio: 'inherit' + }); + + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Script exited with code ${code}`)); + } + }); + + child.on('error', (err) => { + reject(err); + }); + }); +} + +function clearScreen() { + process.stdout.write('\x1Bc'); +} + +const scripts = { + 'Import Scripts': { + '1': { name: 'Full Import From Production', path: './import-from-prod' }, + '2': { name: 'Individual Import Scripts ▸', submenu: { + '1': { name: 'Import Orders', path: './import/orders', key: 'importOrders' }, + '2': { name: 'Import Products', path: './import/products', key: 'importProducts' }, + '3': { name: 'Import Purchase Orders', path: './import/purchase-orders' }, + '4': { name: 'Import Categories', path: './import/categories' }, + 'b': { name: 'Back to Main Menu' } + }} + }, + 'Metrics': { + '3': { name: 'Calculate All Metrics', path: './calculate-metrics' }, + '4': { name: 'Individual Metric Scripts ▸', submenu: { + '1': { name: 'Brand Metrics', path: './metrics/brand-metrics' }, + '2': { name: 'Category Metrics', path: './metrics/category-metrics' }, + '3': { name: 'Financial Metrics', path: './metrics/financial-metrics' }, + '4': { name: 'Product Metrics', path: './metrics/product-metrics' }, + '5': { name: 'Sales Forecasts', path: './metrics/sales-forecasts' }, + '6': { name: 'Time Aggregates', path: './metrics/time-aggregates' }, + '7': { name: 'Vendor Metrics', path: './metrics/vendor-metrics' }, + 'b': { name: 'Back to Main Menu' } + }} + }, + 'Database Management': { + '5': { name: 'Test Production Connection', path: './test-prod-connection' } + }, + 'Reset Scripts': { + '6': { name: 'Reset Database', path: './reset-db' }, + '7': { name: 'Reset Metrics', path: './reset-metrics' } + } +}; + +let lastRun = null; + +async function displayMenu(menuItems, title = 'Inventory Management Script Runner') { + clearScreen(); + console.log(`\n${title}\n`); + + for (const [category, items] of Object.entries(menuItems)) { + console.log(`\n${category}:`); + Object.entries(items).forEach(([key, script]) => { + console.log(`${key}. ${script.name}`); + }); + } + + if (lastRun) { + console.log('\nQuick Access:'); + console.log(`r. Repeat Last Script (${lastRun.name})`); + } + + console.log('\nq. Quit\n'); +} + +async function handleSubmenu(submenu, title) { + while (true) { + await displayMenu({"Individual Scripts": submenu}, title); + const choice = await question('Select an option (or b to go back): '); + + if (choice.toLowerCase() === 'b') { + return null; + } + + if (submenu[choice]) { + return submenu[choice]; + } + + console.log('Invalid selection. Please try again.'); + await new Promise(resolve => setTimeout(resolve, 1000)); + } +} + +async function runScript(script) { + console.log(`\nRunning: ${script.name}`); + try { + const scriptPath = require.resolve(script.path); + await runWithTimeout(scriptPath); + console.log('\nScript completed successfully'); + lastRun = script; + } catch (error) { + console.error('\nError running script:', error); + } + await question('\nPress Enter to continue...'); +} + +async function main() { + while (true) { + await displayMenu(scripts); + + const choice = await question('Select an option: '); + + if (choice.toLowerCase() === 'q') { + break; + } + + if (choice.toLowerCase() === 'r' && lastRun) { + await runScript(lastRun); + continue; + } + + let selectedScript = null; + for (const category of Object.values(scripts)) { + if (category[choice]) { + selectedScript = category[choice]; + break; + } + } + + if (!selectedScript) { + console.log('Invalid selection. Please try again.'); + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + + if (selectedScript.submenu) { + const submenuChoice = await handleSubmenu( + selectedScript.submenu, + selectedScript.name + ); + if (submenuChoice && submenuChoice.path) { + await runScript(submenuChoice); + } + } else if (selectedScript.path) { + await runScript(selectedScript); + } + } + + rl.close(); + process.exit(0); +} + +if (require.main === module) { + main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); + }); +} diff --git a/inventory-server/old/test-connection.js b/inventory-server/old/test-connection.js new file mode 100644 index 0000000..633963b --- /dev/null +++ b/inventory-server/old/test-connection.js @@ -0,0 +1,22 @@ +const express = require('express'); +const router = express.Router(); +const { testConnection } = require('../../scripts/test-prod-connection'); + +router.get('/test-prod-connection', async (req, res) => { + try { + const productCount = await testConnection(); + res.json({ + success: true, + message: 'Successfully connected to production database', + productCount + }); + } catch (error) { + console.error('Production connection test failed:', error); + res.status(500).json({ + success: false, + error: error.message || 'Failed to connect to production database' + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/old/test-prod-connection.js b/inventory-server/old/test-prod-connection.js new file mode 100644 index 0000000..d83216f --- /dev/null +++ b/inventory-server/old/test-prod-connection.js @@ -0,0 +1,89 @@ +const mysql = require('mysql2/promise'); +const { Client } = require('ssh2'); +const dotenv = require('dotenv'); +const path = require('path'); + +dotenv.config({ path: path.join(__dirname, '../.env') }); + +// SSH configuration +const sshConfig = { + host: process.env.PROD_SSH_HOST, + port: process.env.PROD_SSH_PORT || 22, + username: process.env.PROD_SSH_USER, + privateKey: process.env.PROD_SSH_KEY_PATH ? require('fs').readFileSync(process.env.PROD_SSH_KEY_PATH) : undefined +}; + +// Database configuration +const dbConfig = { + host: process.env.PROD_DB_HOST || 'localhost', // Usually localhost when tunneling + user: process.env.PROD_DB_USER, + password: process.env.PROD_DB_PASSWORD, + database: process.env.PROD_DB_NAME, + port: process.env.PROD_DB_PORT || 3306 +}; + +async function testConnection() { + const ssh = new Client(); + + try { + // Create new Promise for SSH connection + await new Promise((resolve, reject) => { + ssh.on('ready', resolve) + .on('error', reject) + .connect(sshConfig); + }); + + console.log('SSH Connection successful!'); + + // Forward local port to remote MySQL port + const tunnel = await new Promise((resolve, reject) => { + ssh.forwardOut( + '127.0.0.1', + 0, + dbConfig.host, + dbConfig.port, + (err, stream) => { + if (err) reject(err); + resolve(stream); + } + ); + }); + + console.log('Port forwarding established'); + + // Create MySQL connection over SSH tunnel + const connection = await mysql.createConnection({ + ...dbConfig, + stream: tunnel + }); + + console.log('MySQL Connection successful!'); + + // Test query + const [rows] = await connection.query('SELECT COUNT(*) as count FROM products'); + console.log('Query successful! Product count:', rows[0].count); + + // Clean up + await connection.end(); + ssh.end(); + console.log('Connections closed successfully'); + return rows[0].count; + + } catch (error) { + console.error('Error:', error); + if (ssh) ssh.end(); + throw error; + } +} + +// If running directly (not imported) +if (require.main === module) { + testConnection() + .then(() => process.exit(0)) + .catch(error => { + console.error('Test failed:', error); + process.exit(1); + }); +} + +module.exports = { testConnection }; \ No newline at end of file diff --git a/inventory-server/old/update-order-costs.js b/inventory-server/old/update-order-costs.js new file mode 100644 index 0000000..3cf6674 --- /dev/null +++ b/inventory-server/old/update-order-costs.js @@ -0,0 +1,337 @@ +/** + * This script updates the costeach values for existing orders from the original MySQL database + * without needing to run the full import process. + */ +const dotenv = require("dotenv"); +const path = require("path"); +const fs = require("fs"); +const { setupConnections, closeConnections } = require('../scripts/import/utils'); +const { outputProgress, formatElapsedTime } = require('./metrics/utils/progress'); + +dotenv.config({ path: path.join(__dirname, "../.env") }); + +// SSH configuration +const sshConfig = { + ssh: { + host: process.env.PROD_SSH_HOST, + port: process.env.PROD_SSH_PORT || 22, + username: process.env.PROD_SSH_USER, + privateKey: process.env.PROD_SSH_KEY_PATH + ? fs.readFileSync(process.env.PROD_SSH_KEY_PATH) + : undefined, + compress: true, // Enable SSH compression + }, + prodDbConfig: { + // MySQL config for production + host: process.env.PROD_DB_HOST || "localhost", + user: process.env.PROD_DB_USER, + password: process.env.PROD_DB_PASSWORD, + database: process.env.PROD_DB_NAME, + port: process.env.PROD_DB_PORT || 3306, + timezone: 'Z', + }, + localDbConfig: { + // PostgreSQL config for local + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432, + ssl: process.env.DB_SSL === 'true', + connectionTimeoutMillis: 60000, + idleTimeoutMillis: 30000, + max: 10 // connection pool max size + } +}; + +async function updateOrderCosts() { + const startTime = Date.now(); + let connections; + let updatedCount = 0; + let errorCount = 0; + + try { + outputProgress({ + status: "running", + operation: "Order costs update", + message: "Initializing SSH tunnel..." + }); + + connections = await setupConnections(sshConfig); + const { prodConnection, localConnection } = connections; + + // 1. Get all orders from local database that need cost updates + outputProgress({ + status: "running", + operation: "Order costs update", + message: "Getting orders from local database..." + }); + + const [orders] = await localConnection.query(` + SELECT DISTINCT order_number, pid + FROM orders + WHERE costeach = 0 OR costeach IS NULL + ORDER BY order_number + `); + + if (!orders || !orders.rows || orders.rows.length === 0) { + console.log("No orders found that need cost updates"); + return { updatedCount: 0, errorCount: 0 }; + } + + const totalOrders = orders.rows.length; + console.log(`Found ${totalOrders} orders that need cost updates`); + + // Process in batches of 1000 orders + const BATCH_SIZE = 500; + for (let i = 0; i < orders.rows.length; i += BATCH_SIZE) { + try { + // Start transaction for this batch + await localConnection.beginTransaction(); + + const batch = orders.rows.slice(i, i + BATCH_SIZE); + + const orderNumbers = [...new Set(batch.map(o => o.order_number))]; + + // 2. Fetch costs from production database for these orders + outputProgress({ + status: "running", + operation: "Order costs update", + message: `Fetching costs for orders ${i + 1} to ${Math.min(i + BATCH_SIZE, totalOrders)} of ${totalOrders}`, + current: i, + total: totalOrders, + elapsed: formatElapsedTime((Date.now() - startTime) / 1000) + }); + + const [costs] = await prodConnection.query(` + SELECT + oc.orderid as order_number, + oc.pid, + oc.costeach + FROM order_costs oc + INNER JOIN ( + SELECT + orderid, + pid, + MAX(id) as max_id + FROM order_costs + WHERE orderid IN (?) + AND pending = 0 + GROUP BY orderid, pid + ) latest ON oc.orderid = latest.orderid AND oc.pid = latest.pid AND oc.id = latest.max_id + `, [orderNumbers]); + + // Create a map of costs for easy lookup + const costMap = {}; + if (costs && costs.length) { + costs.forEach(c => { + costMap[`${c.order_number}-${c.pid}`] = c.costeach || 0; + }); + } + + // 3. Update costs in local database by batches + // Using a more efficient update approach with a temporary table + + // Create a temporary table for each batch + await localConnection.query(` + DROP TABLE IF EXISTS temp_order_costs; + CREATE TEMP TABLE temp_order_costs ( + order_number VARCHAR(50) NOT NULL, + pid BIGINT NOT NULL, + costeach DECIMAL(10,3) NOT NULL, + PRIMARY KEY (order_number, pid) + ); + `); + + // Insert cost data into the temporary table + const costEntries = []; + for (const order of batch) { + const key = `${order.order_number}-${order.pid}`; + if (key in costMap) { + costEntries.push({ + order_number: order.order_number, + pid: order.pid, + costeach: costMap[key] + }); + } + } + + // Insert in sub-batches of 100 + const DB_BATCH_SIZE = 50; + for (let j = 0; j < costEntries.length; j += DB_BATCH_SIZE) { + const subBatch = costEntries.slice(j, j + DB_BATCH_SIZE); + if (subBatch.length === 0) continue; + + const placeholders = subBatch.map((_, idx) => + `($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})` + ).join(','); + + const values = subBatch.flatMap(item => [ + item.order_number, + item.pid, + item.costeach + ]); + + await localConnection.query(` + INSERT INTO temp_order_costs (order_number, pid, costeach) + VALUES ${placeholders} + `, values); + } + + // Perform bulk update from the temporary table + const [updateResult] = await localConnection.query(` + UPDATE orders o + SET costeach = t.costeach + FROM temp_order_costs t + WHERE o.order_number = t.order_number AND o.pid = t.pid + RETURNING o.id + `); + + const batchUpdated = updateResult.rowCount || 0; + updatedCount += batchUpdated; + + // Commit transaction for this batch + await localConnection.commit(); + + outputProgress({ + status: "running", + operation: "Order costs update", + message: `Updated ${updatedCount} orders with costs from production (batch: ${batchUpdated})`, + current: i + batch.length, + total: totalOrders, + elapsed: formatElapsedTime((Date.now() - startTime) / 1000) + }); + } catch (error) { + // If a batch fails, roll back that batch's transaction and continue + try { + await localConnection.rollback(); + } catch (rollbackError) { + console.error("Error during batch rollback:", rollbackError); + } + + console.error(`Error processing batch ${i}-${i + BATCH_SIZE}:`, error); + errorCount++; + } + } + + // 4. For orders with no matching costs, set a default based on price + outputProgress({ + status: "running", + operation: "Order costs update", + message: "Setting default costs for remaining orders..." + }); + + // Process remaining updates in smaller batches + const DEFAULT_BATCH_SIZE = 10000; + let totalDefaultUpdated = 0; + + try { + // Start with a count query to determine how many records need the default update + const [countResult] = await localConnection.query(` + SELECT COUNT(*) as count FROM orders + WHERE (costeach = 0 OR costeach IS NULL) + `); + + const totalToUpdate = parseInt(countResult.rows[0]?.count || 0); + + if (totalToUpdate > 0) { + console.log(`Applying default cost to ${totalToUpdate} orders`); + + // Apply the default in batches with separate transactions + for (let i = 0; i < totalToUpdate; i += DEFAULT_BATCH_SIZE) { + try { + await localConnection.beginTransaction(); + + const [defaultUpdates] = await localConnection.query(` + WITH orders_to_update AS ( + SELECT id FROM orders + WHERE (costeach = 0 OR costeach IS NULL) + LIMIT ${DEFAULT_BATCH_SIZE} + ) + UPDATE orders o + SET costeach = price * 0.5 + FROM orders_to_update otu + WHERE o.id = otu.id + RETURNING o.id + `); + + const batchDefaultUpdated = defaultUpdates.rowCount || 0; + totalDefaultUpdated += batchDefaultUpdated; + + await localConnection.commit(); + + outputProgress({ + status: "running", + operation: "Order costs update", + message: `Applied default costs to ${totalDefaultUpdated} of ${totalToUpdate} orders`, + current: totalDefaultUpdated, + total: totalToUpdate, + elapsed: formatElapsedTime((Date.now() - startTime) / 1000) + }); + } catch (error) { + try { + await localConnection.rollback(); + } catch (rollbackError) { + console.error("Error during default update rollback:", rollbackError); + } + + console.error(`Error applying default costs batch ${i}-${i + DEFAULT_BATCH_SIZE}:`, error); + errorCount++; + } + } + } + } catch (error) { + console.error("Error counting or updating remaining orders:", error); + errorCount++; + } + + updatedCount += totalDefaultUpdated; + + const endTime = Date.now(); + const totalSeconds = (endTime - startTime) / 1000; + + outputProgress({ + status: "complete", + operation: "Order costs update", + message: `Updated ${updatedCount} orders (${totalDefaultUpdated} with default values) in ${formatElapsedTime(totalSeconds)}`, + elapsed: formatElapsedTime(totalSeconds) + }); + + return { + status: "complete", + updatedCount, + errorCount + }; + } catch (error) { + console.error("Error during order costs update:", error); + + return { + status: "error", + error: error.message, + updatedCount, + errorCount + }; + } finally { + if (connections) { + await closeConnections(connections).catch(err => { + console.error("Error closing connections:", err); + }); + } + } +} + +// Run the script only if this is the main module +if (require.main === module) { + updateOrderCosts().then((results) => { + console.log('Cost update completed:', results); + // Force exit after a small delay to ensure all logs are written + setTimeout(() => process.exit(0), 500); + }).catch((error) => { + console.error("Unhandled error:", error); + // Force exit with error code after a small delay + setTimeout(() => process.exit(1), 500); + }); +} + +// Export the function for use in other scripts +module.exports = updateOrderCosts; \ No newline at end of file diff --git a/inventory-server/package-lock.json b/inventory-server/package-lock.json new file mode 100644 index 0000000..e8de9f0 --- /dev/null +++ b/inventory-server/package-lock.json @@ -0,0 +1,3977 @@ +{ + "name": "inventory-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inventory-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@types/diff": "^7.0.1", + "axios": "^1.8.1", + "bcrypt": "^5.1.1", + "commander": "^13.1.0", + "cors": "^2.8.5", + "csv-parse": "^5.6.0", + "diff": "^7.0.0", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "multer": "^1.4.5-lts.1", + "mysql2": "^3.12.0", + "openai": "^6.0.0", + "pg": "^8.14.1", + "pm2": "^5.3.0", + "ssh2": "^1.16.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@mapbox/node-pre-gyp/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/@pm2/agent": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@pm2/agent/-/agent-2.0.4.tgz", + "integrity": "sha512-n7WYvvTJhHLS2oBb1PjOtgLpMhgImOq8sXkPBw6smeg9LJBWZjiEgPKOpR8mn9UJZsB5P3W4V/MyvNnp31LKeA==", + "license": "AGPL-3.0", + "dependencies": { + "async": "~3.2.0", + "chalk": "~3.0.0", + "dayjs": "~1.8.24", + "debug": "~4.3.1", + "eventemitter2": "~5.0.1", + "fast-json-patch": "^3.0.0-1", + "fclone": "~1.0.11", + "nssocket": "0.6.0", + "pm2-axon": "~4.0.1", + "pm2-axon-rpc": "~0.7.0", + "proxy-agent": "~6.3.0", + "semver": "~7.5.0", + "ws": "~7.5.10" + } + }, + "node_modules/@pm2/agent/node_modules/dayjs": { + "version": "1.8.36", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.36.tgz", + "integrity": "sha512-3VmRXEtw7RZKAf+4Tv1Ym9AGeo8r8+CjDi26x+7SYQil1UqtqdaokhzoEJohqlzt0m5kacJSDhJQkG/LWhpRBw==", + "license": "MIT" + }, + "node_modules/@pm2/agent/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@pm2/agent/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pm2/agent/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/@pm2/agent/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pm2/agent/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@pm2/io": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@pm2/io/-/io-6.0.1.tgz", + "integrity": "sha512-KiA+shC6sULQAr9mGZ1pg+6KVW9MF8NpG99x26Lf/082/Qy8qsTCtnJy+HQReW1A9Rdf0C/404cz0RZGZro+IA==", + "license": "Apache-2", + "dependencies": { + "async": "~2.6.1", + "debug": "~4.3.1", + "eventemitter2": "^6.3.1", + "require-in-the-middle": "^5.0.0", + "semver": "~7.5.4", + "shimmer": "^1.2.0", + "signal-exit": "^3.0.3", + "tslib": "1.9.3" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@pm2/io/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/@pm2/io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@pm2/io/node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, + "node_modules/@pm2/io/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pm2/io/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/@pm2/io/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pm2/js-api": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.0.tgz", + "integrity": "sha512-nmWzrA/BQZik3VBz+npRcNIu01kdBhWL0mxKmP1ciF/gTcujPTQqt027N9fc1pK9ERM8RipFhymw7RcmCyOEYA==", + "license": "Apache-2", + "dependencies": { + "async": "^2.6.3", + "debug": "~4.3.1", + "eventemitter2": "^6.3.1", + "extrareqp2": "^1.0.0", + "ws": "^7.0.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@pm2/js-api/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/@pm2/js-api/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@pm2/js-api/node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, + "node_modules/@pm2/js-api/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/@pm2/js-api/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@pm2/pm2-version-check": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz", + "integrity": "sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1" + } + }, + "node_modules/@pm2/pm2-version-check/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@pm2/pm2-version-check/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/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/diff": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz", + "integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==", + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/amp": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz", + "integrity": "sha512-OwIuC4yZaRogHKiuU5WlMR5Xk/jAcpPtawWL05Gj8Lvm2F6mwoJt4O/bHI+DHwG79vWd+8OFYM4/BzYqyRd3qw==", + "license": "MIT" + }, + "node_modules/amp-message": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/amp-message/-/amp-message-0.1.2.tgz", + "integrity": "sha512-JqutcFwoU1+jhv7ArgW38bqrE+LQdcRv4NxNw0mp0JHQyB6tXesWRjtYKlDgHRY2o3JE5UTaBGUK8kSWUdxWUg==", + "license": "MIT", + "dependencies": { + "amp": "0.3.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types/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" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "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/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==", + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/bodec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bodec/-/bodec-0.1.0.tgz", + "integrity": "sha512-Ylo+MAo5BDUq1KA3f3R/MFhh+g8cnHmo8bz3YPGhI1znrMaf77ol1sfvYJzsw3nTE+Y2GryfDxBaR+AqpAkEHQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/charm": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz", + "integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==", + "license": "MIT/X11" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-tableau": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cli-tableau/-/cli-tableau-2.0.1.tgz", + "integrity": "sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==", + "dependencies": { + "chalk": "3.0.0" + }, + "engines": { + "node": ">=8.10.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-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "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/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/croner": { + "version": "4.1.97", + "resolved": "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz", + "integrity": "sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==", + "license": "MIT" + }, + "node_modules/csv-parse": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", + "license": "MIT" + }, + "node_modules/culvert": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz", + "integrity": "sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "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/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "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/enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/eventemitter2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz", + "integrity": "sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extrareqp2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/extrareqp2/-/extrareqp2-1.0.0.tgz", + "integrity": "sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, + "node_modules/fclone": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz", + "integrity": "sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "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.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/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/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "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/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "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/get-uri": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", + "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/get-uri/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/git-node-fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/git-node-fs/-/git-node-fs-1.0.0.tgz", + "integrity": "sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==", + "license": "MIT" + }, + "node_modules/git-sha1": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/git-sha1/-/git-sha1-0.1.2.tgz", + "integrity": "sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "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.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/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/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/js-git": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/js-git/-/js-git-0.7.8.tgz", + "integrity": "sha512-+E5ZH/HeRnoc/LW0AmAyhU+mNcWBzAKE+30+IDMLSLbbK+Tdt02AdkOKq9u15rlJsDEGFqtgckc8ZM59LhhiUA==", + "license": "MIT", + "dependencies": { + "bodec": "^0.1.0", + "culvert": "^0.1.2", + "git-sha1": "^0.1.2", + "pako": "^0.2.5" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/lazy": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", + "integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==", + "license": "MIT", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lru.min": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.1.tgz", + "integrity": "sha512-FbAj6lXil6t8z4z3j0E5mfRlPzxkySotzUHwRXjlpRh10vc6AI6WN62ehZj82VG7M20rqogJ0GLwar2Xa05a8Q==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", + "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/mysql2": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", + "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "license": "MIT", + "optional": true + }, + "node_modules/needle": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.0.tgz", + "integrity": "sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/needle/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": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nssocket": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/nssocket/-/nssocket-0.6.0.tgz", + "integrity": "sha512-a9GSOIql5IqgWJR3F/JXG4KpJTA3Z53Cj0MeMvGpglytB1nxE4PdFNC0jINe27CS7cGivoynwc054EzCcT3M3w==", + "license": "MIT", + "dependencies": { + "eventemitter2": "~0.4.14", + "lazy": "~1.0.11" + }, + "engines": { + "node": ">= 0.10.x" + } + }, + "node_modules/nssocket/node_modules/eventemitter2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", + "integrity": "sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ==", + "license": "MIT" + }, + "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.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "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/openai": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.0.0.tgz", + "integrity": "sha512-J7LEmTn3WLZnbyEmMYcMPyT5A0fGzhPwSvVUcNRKy6j2hJIbqSFrJERnUHYNkcoCCalRumypnj9AVoe5bVHd3Q==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz", + "integrity": "sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/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/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", + "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.8.0", + "pg-protocol": "^1.8.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", + "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", + "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidusage": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-3.0.2.tgz", + "integrity": "sha512-g0VU+y08pKw5M8EZ2rIGiEBaB8wrQMjYGFfW2QVIfyT8V+fq8YFLkvlz4bz5ljvFDJYNFCWT3PWqcRr2FKO81w==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pm2": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/pm2/-/pm2-5.4.3.tgz", + "integrity": "sha512-4/I1htIHzZk1Y67UgOCo4F1cJtas1kSds31N8zN0PybO230id1nigyjGuGFzUnGmUFPmrJ0On22fO1ChFlp7VQ==", + "license": "AGPL-3.0", + "dependencies": { + "@pm2/agent": "~2.0.0", + "@pm2/io": "~6.0.1", + "@pm2/js-api": "~0.8.0", + "@pm2/pm2-version-check": "latest", + "async": "~3.2.0", + "blessed": "0.1.81", + "chalk": "3.0.0", + "chokidar": "^3.5.3", + "cli-tableau": "^2.0.0", + "commander": "2.15.1", + "croner": "~4.1.92", + "dayjs": "~1.11.5", + "debug": "^4.3.1", + "enquirer": "2.3.6", + "eventemitter2": "5.0.1", + "fclone": "1.0.11", + "js-yaml": "~4.1.0", + "mkdirp": "1.0.4", + "needle": "2.4.0", + "pidusage": "~3.0", + "pm2-axon": "~4.0.1", + "pm2-axon-rpc": "~0.7.1", + "pm2-deploy": "~1.0.2", + "pm2-multimeter": "^0.1.2", + "promptly": "^2", + "semver": "^7.2", + "source-map-support": "0.5.21", + "sprintf-js": "1.1.2", + "vizion": "~2.2.1" + }, + "bin": { + "pm2": "bin/pm2", + "pm2-dev": "bin/pm2-dev", + "pm2-docker": "bin/pm2-docker", + "pm2-runtime": "bin/pm2-runtime" + }, + "engines": { + "node": ">=12.0.0" + }, + "optionalDependencies": { + "pm2-sysmonit": "^1.2.8" + } + }, + "node_modules/pm2-axon": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pm2-axon/-/pm2-axon-4.0.1.tgz", + "integrity": "sha512-kES/PeSLS8orT8dR5jMlNl+Yu4Ty3nbvZRmaAtROuVm9nYYGiaoXqqKQqQYzWQzMYWUKHMQTvBlirjE5GIIxqg==", + "license": "MIT", + "dependencies": { + "amp": "~0.3.1", + "amp-message": "~0.1.1", + "debug": "^4.3.1", + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=5" + } + }, + "node_modules/pm2-axon-rpc": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/pm2-axon-rpc/-/pm2-axon-rpc-0.7.1.tgz", + "integrity": "sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1" + }, + "engines": { + "node": ">=5" + } + }, + "node_modules/pm2-axon-rpc/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pm2-axon-rpc/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/pm2-axon/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pm2-axon/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/pm2-deploy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pm2-deploy/-/pm2-deploy-1.0.2.tgz", + "integrity": "sha512-YJx6RXKrVrWaphEYf++EdOOx9EH18vM8RSZN/P1Y+NokTKqYAca/ejXwVLyiEpNju4HPZEk3Y2uZouwMqUlcgg==", + "license": "MIT", + "dependencies": { + "run-series": "^1.1.8", + "tv4": "^1.3.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pm2-multimeter": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/pm2-multimeter/-/pm2-multimeter-0.1.2.tgz", + "integrity": "sha512-S+wT6XfyKfd7SJIBqRgOctGxaBzUOmVQzTAS+cg04TsEUObJVreha7lvCfX8zzGVr871XwCSnHUU7DQQ5xEsfA==", + "license": "MIT/X11", + "dependencies": { + "charm": "~0.1.1" + } + }, + "node_modules/pm2-sysmonit": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/pm2-sysmonit/-/pm2-sysmonit-1.2.8.tgz", + "integrity": "sha512-ACOhlONEXdCTVwKieBIQLSi2tQZ8eKinhcr9JpZSUAL8Qy0ajIgRtsLxG/lwPOW3JEKqPyw/UaHmTWhUzpP4kA==", + "license": "Apache", + "optional": true, + "dependencies": { + "async": "^3.2.0", + "debug": "^4.3.1", + "pidusage": "^2.0.21", + "systeminformation": "^5.7", + "tx2": "~1.0.4" + } + }, + "node_modules/pm2-sysmonit/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pm2-sysmonit/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", + "optional": true + }, + "node_modules/pm2-sysmonit/node_modules/pidusage": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-2.0.21.tgz", + "integrity": "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pm2/node_modules/commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "license": "MIT" + }, + "node_modules/pm2/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pm2/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/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/promptly": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/promptly/-/promptly-2.2.0.tgz", + "integrity": "sha512-aC9j+BZsRSSzEsXBNBwDnAxujdx19HycZoKgRgzWnS8eOHg1asuf9heuLprfbe739zY3IdUQx+Egv6Jn135WHA==", + "license": "MIT", + "dependencies": { + "read": "^1.0.4" + } + }, + "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-agent": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.1.tgz", + "integrity": "sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-agent/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/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-5.2.0.tgz", + "integrity": "sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/require-in-the-middle/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/require-in-the-middle/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/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-series": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz", + "integrity": "sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/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/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "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/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/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/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "license": "BSD-3-Clause" + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/systeminformation": { + "version": "5.25.6", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.25.6.tgz", + "integrity": "sha512-MQbI9T7M5EFQP5ILDNd1fWQy3SK+TLnd4ywa3k+aQMTdpLw0omlO7X25q+QqStOgJYpXeJCV0mW+2i4THTEdIQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", + "license": "Apache-2.0" + }, + "node_modules/tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "license": [ + { + "type": "Public Domain", + "url": "http://geraintluff.github.io/tv4/LICENSE.txt" + }, + { + "type": "MIT", + "url": "http://jsonary.com/LICENSE.txt" + } + ], + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/tx2": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tx2/-/tx2-1.0.5.tgz", + "integrity": "sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==", + "license": "MIT", + "optional": true, + "dependencies": { + "json-stringify-safe": "^5.0.1" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/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/vizion": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vizion/-/vizion-2.2.1.tgz", + "integrity": "sha512-sfAcO2yeSU0CSPFI/DmZp3FsFE9T+8913nv1xWBOyzODv13fwkn6Vl7HqxGpkr9F608M+8SuFId3s+BlZqfXww==", + "license": "Apache-2.0", + "dependencies": { + "async": "^2.6.3", + "git-node-fs": "^1.0.0", + "ini": "^1.3.5", + "js-git": "^0.7.8" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/vizion/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "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/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/inventory-server/package.json b/inventory-server/package.json new file mode 100644 index 0000000..9b2b54e --- /dev/null +++ b/inventory-server/package.json @@ -0,0 +1,41 @@ +{ + "name": "inventory-server", + "version": "1.0.0", + "description": "Backend server for inventory management system", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js", + "prod": "pm2 start ecosystem.config.js", + "prod:stop": "pm2 stop inventory-server", + "prod:restart": "pm2 restart inventory-server", + "prod:logs": "pm2 logs inventory-server", + "prod:status": "pm2 status inventory-server", + "setup": "mkdir -p logs uploads", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@types/diff": "^7.0.1", + "axios": "^1.8.1", + "bcrypt": "^5.1.1", + "commander": "^13.1.0", + "cors": "^2.8.5", + "csv-parse": "^5.6.0", + "diff": "^7.0.0", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "multer": "^1.4.5-lts.1", + "mysql2": "^3.12.0", + "openai": "^6.0.0", + "pg": "^8.14.1", + "pm2": "^5.3.0", + "ssh2": "^1.16.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/inventory-server/scripts/calculate-metrics-new.js b/inventory-server/scripts/calculate-metrics-new.js new file mode 100644 index 0000000..c0dd718 --- /dev/null +++ b/inventory-server/scripts/calculate-metrics-new.js @@ -0,0 +1,908 @@ +// run-all-updates.js +const path = require('path'); +const fs = require('fs'); +const { Pool } = require('pg'); // Assuming you use 'pg' + +// --- Configuration --- +// Toggle these constants to enable/disable specific steps for testing +const RUN_DAILY_SNAPSHOTS = true; +const RUN_PRODUCT_METRICS = true; +const RUN_PERIODIC_METRICS = true; +const RUN_BRAND_METRICS = true; +const RUN_VENDOR_METRICS = true; +const RUN_CATEGORY_METRICS = true; + +// Maximum execution time for the entire sequence (e.g., 90 minutes) +const MAX_EXECUTION_TIME_TOTAL = 90 * 60 * 1000; +// Maximum execution time per individual SQL step (e.g., 30 minutes) +const MAX_EXECUTION_TIME_PER_STEP = 30 * 60 * 1000; +// Query cancellation timeout +const CANCEL_QUERY_AFTER_SECONDS = 5; +// --- End Configuration --- + +// Change working directory to script directory +process.chdir(path.dirname(__filename)); + +// Log script path for debugging +console.log('Script running from:', __dirname); + +// Try to load environment variables from multiple locations +const envPaths = [ + path.resolve(__dirname, '../..', '.env'), // Two levels up (inventory/.env) + path.resolve(__dirname, '..', '.env'), // One level up (inventory-server/.env) + path.resolve(__dirname, '.env'), // Same directory + '/var/www/html/inventory/.env' // Server absolute path +]; + +let envLoaded = false; +for (const envPath of envPaths) { + if (fs.existsSync(envPath)) { + console.log(`Loading environment from: ${envPath}`); + require('dotenv').config({ path: envPath }); + envLoaded = true; + break; + } +} + +if (!envLoaded) { + console.warn('WARNING: Could not find .env file in any of the expected locations.'); + console.warn('Checked paths:', envPaths); +} + +// --- Database Setup --- +// Make sure we have the required DB credentials +if (!process.env.DB_HOST && !process.env.DATABASE_URL) { + console.error('WARNING: Neither DB_HOST nor DATABASE_URL environment variables found'); +} + +// Only validate individual parameters if not using connection string +if (!process.env.DATABASE_URL) { + if (!process.env.DB_USER) console.error('WARNING: DB_USER environment variable is missing'); + if (!process.env.DB_NAME) console.error('WARNING: DB_NAME environment variable is missing'); + + // Password must be a string for PostgreSQL SCRAM authentication + if (!process.env.DB_PASSWORD || typeof process.env.DB_PASSWORD !== 'string') { + console.error('WARNING: DB_PASSWORD environment variable is missing or not a string'); + } +} + +// Configure database connection to match individual scripts +let dbConfig; + +// Check if a DATABASE_URL exists (common in production environments) +if (process.env.DATABASE_URL && typeof process.env.DATABASE_URL === 'string') { + console.log('Using DATABASE_URL for connection'); + dbConfig = { + connectionString: process.env.DATABASE_URL, + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + // Add performance optimizations + max: 10, // connection pool max size + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 60000, + // Set timeouts for long-running queries + statement_timeout: 1800000, // 30 minutes + query_timeout: 1800000 // 30 minutes + }; +} else { + // Use individual connection parameters + dbConfig = { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432, + ssl: process.env.DB_SSL === 'true', + // Add performance optimizations + max: 10, // connection pool max size + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 60000, + // Set timeouts for long-running queries + statement_timeout: 1800000, // 30 minutes + query_timeout: 1800000 // 30 minutes + }; +} + +// Try to load from utils DB module as a last resort +try { + if (!process.env.DB_HOST && !process.env.DATABASE_URL) { + console.log('Attempting to load DB config from individual script modules...'); + const dbModule = require('./metrics-new/utils/db'); + if (dbModule && dbModule.dbConfig) { + console.log('Found DB config in individual script module'); + dbConfig = { + ...dbModule.dbConfig, + // Add performance optimizations if not present + max: dbModule.dbConfig.max || 10, + idleTimeoutMillis: dbModule.dbConfig.idleTimeoutMillis || 30000, + connectionTimeoutMillis: dbModule.dbConfig.connectionTimeoutMillis || 60000, + statement_timeout: 1800000, + query_timeout: 1800000 + }; + } + } +} catch (err) { + console.warn('Could not load DB config from individual script modules:', err.message); +} + +// Debug log connection info (without password) +console.log('DB Connection Info:', { + connectionString: dbConfig.connectionString ? 'PROVIDED' : undefined, + host: dbConfig.host, + user: dbConfig.user, + database: dbConfig.database, + port: dbConfig.port, + ssl: dbConfig.ssl ? 'ENABLED' : 'DISABLED', + password: (dbConfig.password || dbConfig.connectionString) ? '****' : 'MISSING' // Only show if credentials exist +}); + +const pool = new Pool(dbConfig); + +const getConnection = () => { + return pool.connect(); +}; + +const closePool = () => { + console.log("Closing database connection pool."); + return pool.end(); +}; + +// --- Progress Utilities --- +// Using functions directly instead of globals +const progressUtils = require('./metrics-new/utils/progress'); // Assuming utils/progress.js exports these + +// --- State & Cancellation --- +let isCancelled = false; +let currentStep = ''; // Track which step is running for cancellation message +let overallStartTime = null; +let mainTimeoutHandle = null; +let stepTimeoutHandle = null; +let combinedHistoryId = null; // ID for the combined history record + +async function cancelCalculation(reason = 'cancelled by user') { + if (isCancelled) return; // Prevent multiple cancellations + isCancelled = true; + console.log(`Calculation ${reason}. Attempting to cancel active step: ${currentStep}`); + + // Clear timeouts + if (mainTimeoutHandle) clearTimeout(mainTimeoutHandle); + if (stepTimeoutHandle) clearTimeout(stepTimeoutHandle); + + // Attempt to cancel the long-running query in Postgres + let conn = null; + try { + console.log(`Attempting to cancel queries running longer than ${CANCEL_QUERY_AFTER_SECONDS} seconds...`); + conn = await getConnection(); + const result = await conn.query(` + SELECT pg_cancel_backend(pid) + FROM pg_stat_activity + WHERE query_start < now() - interval '${CANCEL_QUERY_AFTER_SECONDS} seconds' + AND application_name = 'node-metrics-calculator' -- Match specific app name + AND state = 'active' -- Only cancel active queries + AND query NOT LIKE '%pg_cancel_backend%' + AND pid <> pg_backend_pid(); -- Don't cancel self + `); + console.log(`Sent ${result.rowCount} cancellation signal(s).`); + + // Update the combined history record to show cancellation + if (combinedHistoryId) { + const totalDuration = Math.round((Date.now() - overallStartTime) / 1000); + await conn.query(` + UPDATE calculate_history + SET + status = 'cancelled'::calculation_status, + end_time = NOW(), + duration_seconds = $1::integer, + error_message = $2::text + WHERE id = $3::integer; + `, [totalDuration, `Calculation ${reason} during step: ${currentStep}`, combinedHistoryId]); + console.log(`Updated combined history record ${combinedHistoryId} with cancellation status`); + } + + conn.release(); + } catch (err) { + console.error('Error during database query cancellation:', err.message); + if (conn) { + try { conn.release(); } catch (e) { console.error("Error releasing cancellation connection", e); } + } + // Proceed with script termination attempt even if DB cancel fails + } finally { + // Update progress to show cancellation + progressUtils.outputProgress({ + status: 'cancelled', + operation: `Calculation ${reason} during step: ${currentStep}`, + current: 0, // Reset progress indicators + total: 100, + elapsed: overallStartTime ? progressUtils.formatElapsedTime(overallStartTime) : 'N/A', + remaining: null, + rate: 0, + percentage: '0', // Or keep last known percentage? + timing: { + start_time: overallStartTime ? new Date(overallStartTime).toISOString() : 'N/A', + end_time: new Date().toISOString(), + elapsed_seconds: overallStartTime ? Math.round((Date.now() - overallStartTime) / 1000) : 0 + } + }); + } + + // Note: We don't force exit here anymore. We let the main function's error + // handling catch the cancellation error thrown by executeSqlStep or the timeout. + return { + success: true, // Indicates cancellation was initiated + message: `Calculation ${reason}` + }; +} + +// Handle SIGINT (Ctrl+C) and SIGTERM (kill) signals +process.on('SIGINT', () => { + console.log('\nReceived SIGINT (Ctrl+C).'); + cancelCalculation('cancelled by user (SIGINT)'); + // Give cancellation a moment to propagate before force-exiting if needed + setTimeout(() => process.exit(1), 2000); +}); +process.on('SIGTERM', () => { + console.log('Received SIGTERM.'); + cancelCalculation('cancelled by system (SIGTERM)'); + // Give cancellation a moment to propagate before force-exiting if needed + setTimeout(() => process.exit(1), 2000); +}); + +// Add error handlers for uncaught exceptions/rejections +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + // Attempt graceful shutdown/logging if possible, then exit + cancelCalculation('failed due to uncaught exception').finally(() => { + closePool().finally(() => process.exit(1)); + }); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + // Attempt graceful shutdown/logging if possible, then exit + cancelCalculation('failed due to unhandled rejection').finally(() => { + closePool().finally(() => process.exit(1)); + }); +}); + + +// --- Core Logic --- + +/** + * Ensures all products have entries in the settings_product table + * This is important after importing new products + */ +async function syncSettingsProductTable() { + let conn = null; + try { + currentStep = 'Syncing settings_product table'; + progressUtils.outputProgress({ + operation: 'Syncing product settings', + message: 'Ensuring all products have settings entries' + }); + + conn = await getConnection(); + + // Get counts before sync + const beforeCounts = await conn.query(` + SELECT + (SELECT COUNT(*) FROM products) AS products_count, + (SELECT COUNT(*) FROM settings_product) AS settings_count + `); + + const productsCount = parseInt(beforeCounts.rows[0].products_count); + const settingsCount = parseInt(beforeCounts.rows[0].settings_count); + + progressUtils.outputProgress({ + operation: 'Settings product sync', + message: `Found ${productsCount} products and ${settingsCount} settings entries` + }); + + // Insert missing product settings + const result = await conn.query(` + INSERT INTO settings_product ( + pid, + lead_time_days, + days_of_stock, + safety_stock, + forecast_method, + exclude_from_forecast + ) + SELECT + p.pid, + CAST(NULL AS INTEGER), + CAST(NULL AS INTEGER), + COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0), + CAST(NULL AS VARCHAR), + FALSE + FROM + public.products p + WHERE + NOT EXISTS ( + SELECT 1 FROM settings_product sp WHERE sp.pid = p.pid + ) + ON CONFLICT (pid) DO NOTHING + `); + + // Get counts after sync + const afterCounts = await conn.query(` + SELECT COUNT(*) AS settings_count FROM settings_product + `); + + const newSettingsCount = parseInt(afterCounts.rows[0].settings_count); + const addedCount = newSettingsCount - settingsCount; + + progressUtils.outputProgress({ + operation: 'Settings product sync', + message: `Added ${addedCount} new settings entries. Now have ${newSettingsCount} total entries.`, + status: 'complete' + }); + + conn.release(); + return addedCount; + } catch (err) { + progressUtils.outputProgress({ + status: 'error', + operation: 'Settings product sync failed', + error: err.message + }); + if (conn) conn.release(); + throw err; + } +} + +/** + * Executes a single SQL calculation step. + * @param {object} config - Configuration for the step. + * @param {string} config.name - User-friendly name of the step. + * @param {string} config.sqlFile - Path to the SQL file. + * @param {string} config.historyType - Type identifier for calculate_history. + * @param {string} config.statusModule - Module name for calculate_status. + * @param {object} progress - Progress utility functions. + * @returns {Promise<{success: boolean, message: string, duration: number, rowsAffected: number}>} + */ +async function executeSqlStep(config, progress) { + if (isCancelled) throw new Error(`Calculation skipped step ${config.name} due to prior cancellation.`); + + currentStep = config.name; // Update global state + console.log(`\n--- Starting Step: ${config.name} ---`); + const stepStartTime = Date.now(); + let connection = null; + let rowsAffected = 0; // Track rows affected by this step + + // Set timeout for this specific step + if (stepTimeoutHandle) clearTimeout(stepTimeoutHandle); // Clear previous step's timeout + stepTimeoutHandle = setTimeout(() => { + // Don't exit directly, throw an error to be caught by the main loop + const timeoutError = new Error(`Step "${config.name}" timed out after ${MAX_EXECUTION_TIME_PER_STEP / 1000} seconds.`); + cancelCalculation(`timed out during step: ${config.name}`); // Initiate cancellation process + // The error will likely be thrown before cancelCalculation fully completes, + // but cancelCalculation attempts to stop the query. + // The main catch block will handle cleanup. + }, MAX_EXECUTION_TIME_PER_STEP); + + + try { + // 1. Read SQL File + const sqlFilePath = path.resolve(__dirname, config.sqlFile); + if (!fs.existsSync(sqlFilePath)) { + throw new Error(`SQL file not found: ${sqlFilePath}`); + } + const sqlQuery = fs.readFileSync(sqlFilePath, 'utf8'); + console.log(`Read SQL file: ${config.sqlFile}`); + + // Check for potential parameter references that might cause issues + const parameterMatches = sqlQuery.match(/\$\d+(?!\:\:)/g); + if (parameterMatches && parameterMatches.length > 0) { + console.warn(`WARNING: Found ${parameterMatches.length} untyped parameters in SQL: ${parameterMatches.slice(0, 5).join(', ')}${parameterMatches.length > 5 ? '...' : ''}`); + console.warn('These might cause "could not determine data type of parameter" errors.'); + } + + // 2. Get Database Connection + connection = await getConnection(); + console.log("Database connection acquired."); + + // 3. Ensure calculate_status table exists + await connection.query(` + CREATE TABLE IF NOT EXISTS calculate_status ( + module_name TEXT PRIMARY KEY, + last_calculation_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + + // 4. Initial Progress Update + progress.outputProgress({ + status: 'running', + operation: `Starting: ${config.name}`, + current: 0, total: 100, + elapsed: progress.formatElapsedTime(stepStartTime), + remaining: 'Calculating...', rate: 0, percentage: '0', + timing: { + start_time: new Date(stepStartTime).toISOString(), + step_start_ms: stepStartTime + } + }); + + // 5. Execute the Main SQL Query + progress.outputProgress({ + status: 'running', + operation: `Executing SQL: ${config.name}`, + current: 25, total: 100, + elapsed: progress.formatElapsedTime(stepStartTime), + remaining: 'Executing query...', rate: 0, percentage: '25', + timing: { + start_time: new Date(stepStartTime).toISOString(), + step_start_ms: stepStartTime + } + }); + console.log(`Executing SQL for ${config.name}...`); + + try { + // Try executing exactly as individual scripts do + const result = await connection.query(sqlQuery); + + // Try to extract row count from result + if (result && result.rowCount !== undefined) { + rowsAffected = result.rowCount; + } else if (Array.isArray(result) && result[0] && result[0].rowCount !== undefined) { + rowsAffected = result[0].rowCount; + } + + // Check if the query returned a result set with row count info + if (result && result.rows && result.rows.length > 0 && result.rows[0].rows_processed) { + rowsAffected = parseInt(result.rows[0].rows_processed) || rowsAffected; + console.log(`SQL returned metrics: ${JSON.stringify(result.rows[0])}`); + } else if (Array.isArray(result) && result[0] && result[0].rows && result[0].rows[0] && result[0].rows[0].rows_processed) { + rowsAffected = parseInt(result[0].rows[0].rows_processed) || rowsAffected; + console.log(`SQL returned metrics: ${JSON.stringify(result[0].rows[0])}`); + } + + console.log(`SQL affected ${rowsAffected} rows`); + } catch (sqlError) { + if (sqlError.message.includes('could not determine data type of parameter')) { + console.log('Simple query failed with parameter type error, trying alternative method...'); + try { + // Execute with explicit text mode to avoid parameter confusion + await connection.query({ + text: sqlQuery, + rowMode: 'text' + }); + } catch (altError) { + console.error('Alternative execution method also failed:', altError.message); + throw altError; // Re-throw the alternative error + } + } else { + console.error('SQL Execution Error:', sqlError.message); + if (sqlError.position) { + // If the error has a position, try to show the relevant part of the SQL query + const position = parseInt(sqlError.position, 10); + const startPos = Math.max(0, position - 100); + const endPos = Math.min(sqlQuery.length, position + 100); + console.error('SQL Error Context:'); + console.error('...' + sqlQuery.substring(startPos, position) + ' [ERROR HERE] ' + sqlQuery.substring(position, endPos) + '...'); + } + throw sqlError; // Re-throw to be caught by the main try/catch + } + } + + // Check for cancellation immediately after query finishes + if (isCancelled) throw new Error(`Calculation cancelled during SQL execution for ${config.name}`); + + console.log(`SQL execution finished for ${config.name}.`); + + // 6. Update Status table only + await connection.query(` + INSERT INTO calculate_status (module_name, last_calculation_timestamp) + VALUES ($1::text, NOW()) + ON CONFLICT (module_name) DO UPDATE + SET last_calculation_timestamp = EXCLUDED.last_calculation_timestamp; + `, [config.statusModule]); + + const stepDuration = Math.round((Date.now() - stepStartTime) / 1000); + + // 7. Final Progress Update for Step + progress.outputProgress({ + status: 'complete', + operation: `Completed: ${config.name}`, + current: 100, total: 100, + elapsed: progress.formatElapsedTime(stepStartTime), + remaining: '0s', rate: 0, percentage: '100', + timing: { + start_time: new Date(stepStartTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: stepDuration + } + }); + console.log(`--- Finished Step: ${config.name} (Duration: ${progress.formatElapsedTime(stepStartTime)}) ---`); + + return { + success: true, + message: `${config.name} completed successfully`, + duration: stepDuration, + rowsAffected: rowsAffected + }; + + } catch (error) { + clearTimeout(stepTimeoutHandle); // Clear timeout on error + const errorEndTime = Date.now(); + const errorDuration = Math.round((errorEndTime - stepStartTime) / 1000); + const finalStatus = isCancelled ? 'cancelled' : 'failed'; + const errorMessage = error.message || 'Unknown error'; + + console.error(`--- ERROR in Step: ${config.name} ---`); + console.error(error); // Log the full error + console.error(`------------------------------------`); + + // Update progress file with error/cancellation + progress.outputProgress({ + status: finalStatus, + operation: `Error in ${config.name}: ${errorMessage.split('\n')[0]}`, // Show first line of error + current: 50, total: 100, // Indicate partial completion + elapsed: progress.formatElapsedTime(stepStartTime), + remaining: null, rate: 0, percentage: '50', + timing: { + start_time: new Date(stepStartTime).toISOString(), + end_time: new Date(errorEndTime).toISOString(), + elapsed_seconds: errorDuration + } + }); + + // Rethrow the error to be caught by the main runCalculations function + throw error; // Add context if needed: new Error(`Step ${config.name} failed: ${errorMessage}`) + + } finally { + clearTimeout(stepTimeoutHandle); // Ensure timeout is cleared + currentStep = ''; // Reset current step + if (connection) { + try { + await connection.release(); + console.log("Database connection released."); + } catch (releaseError) { + console.error("Error releasing database connection:", releaseError); + } + } + } +} + +/** + * Main function to run all calculation steps sequentially. + */ +async function runAllCalculations() { + overallStartTime = Date.now(); + isCancelled = false; // Reset cancellation flag at start + + // Overall timeout for the entire script + mainTimeoutHandle = setTimeout(() => { + console.error(`--- OVERALL TIMEOUT REACHED (${MAX_EXECUTION_TIME_TOTAL / 1000}s) ---`); + cancelCalculation(`overall timeout reached`); + // The process should exit via the unhandled rejection/exception handlers + // or the SIGTERM/SIGINT handlers after cancellation attempt. + }, MAX_EXECUTION_TIME_TOTAL); + + const steps = [ + { + run: RUN_DAILY_SNAPSHOTS, + name: 'Daily Snapshots Update', + sqlFile: 'metrics-new/update_daily_snapshots.sql', + historyType: 'daily_snapshots', + statusModule: 'daily_snapshots' + }, + { + run: RUN_PRODUCT_METRICS, + name: 'Product Metrics Update', + sqlFile: 'metrics-new/update_product_metrics.sql', // ASSUMING the initial population is now part of a regular update + historyType: 'product_metrics', + statusModule: 'product_metrics' + }, + { + run: RUN_PERIODIC_METRICS, + name: 'Periodic Metrics Update', + sqlFile: 'metrics-new/update_periodic_metrics.sql', + historyType: 'periodic_metrics', + statusModule: 'periodic_metrics' + }, + { + run: RUN_BRAND_METRICS, + name: 'Brand Metrics Update', + sqlFile: 'metrics-new/calculate_brand_metrics.sql', + historyType: 'brand_metrics', + statusModule: 'brand_metrics' + }, + { + run: RUN_VENDOR_METRICS, + name: 'Vendor Metrics Update', + sqlFile: 'metrics-new/calculate_vendor_metrics.sql', + historyType: 'vendor_metrics', + statusModule: 'vendor_metrics' + }, + { + run: RUN_CATEGORY_METRICS, + name: 'Category Metrics Update', + sqlFile: 'metrics-new/calculate_category_metrics.sql', + historyType: 'category_metrics', + statusModule: 'category_metrics' + } + ]; + + // Build a list of steps we will actually run + const stepsToRun = steps.filter(step => step.run); + const stepNames = stepsToRun.map(step => step.name); + const sqlFiles = stepsToRun.map(step => step.sqlFile); + + let overallSuccess = true; + let connection = null; + + try { + // Create a single history record before starting all calculations + try { + connection = await getConnection(); + + // Ensure calculate_history table exists (basic structure) + await connection.query(` + CREATE TABLE IF NOT EXISTS calculate_history ( + id SERIAL PRIMARY KEY, + start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP WITH TIME ZONE, + duration_seconds INTEGER, + status TEXT, -- Will be altered to enum if needed below + error_message TEXT, + additional_info JSONB + ); + `); + + // Ensure the calculation_status enum type exists if needed + await connection.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'calculation_status') THEN + CREATE TYPE calculation_status AS ENUM ('running', 'completed', 'failed', 'cancelled'); + + -- If needed, alter the existing table to use the enum + ALTER TABLE calculate_history + ALTER COLUMN status TYPE calculation_status + USING status::calculation_status; + END IF; + END + $$; + `); + + // Mark any previous running combined calculations as cancelled + await connection.query(` + UPDATE calculate_history + SET + status = 'cancelled'::calculation_status, + end_time = NOW(), + duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER, + error_message = 'Previous calculation was not completed properly or was superseded.' + WHERE status = 'running'::calculation_status AND additional_info->>'type' = 'combined_metrics'; + `); + + // Create a single history record for this run + const historyResult = await connection.query(` + INSERT INTO calculate_history (status, additional_info) + VALUES ('running'::calculation_status, jsonb_build_object( + 'type', 'combined_metrics', + 'steps', $1::jsonb, + 'sql_files', $2::jsonb + )) + RETURNING id; + `, [JSON.stringify(stepNames), JSON.stringify(sqlFiles)]); + + combinedHistoryId = historyResult.rows[0].id; + console.log(`Created combined history record ID: ${combinedHistoryId}`); + + // Get initial counts for tracking + const productCount = await connection.query('SELECT COUNT(*) as count FROM products'); + const totalProducts = parseInt(productCount.rows[0].count); + + // Update history with initial counts + await connection.query(` + UPDATE calculate_history + SET additional_info = additional_info || jsonb_build_object('total_products', $1::integer) + WHERE id = $2 + `, [totalProducts, combinedHistoryId]); + + connection.release(); + } catch (historyError) { + console.error('Error creating combined history record:', historyError); + if (connection) connection.release(); + // Continue without history tracking if it fails + } + + // First, sync the settings_product table to ensure all products have entries + progressUtils.outputProgress({ + operation: 'Starting metrics calculation', + message: 'Preparing product settings...' + }); + + try { + const addedCount = await syncSettingsProductTable(); + + progressUtils.outputProgress({ + operation: 'Preparation complete', + message: `Added ${addedCount} missing product settings entries`, + status: 'complete' + }); + } catch (syncError) { + console.error('Warning: Failed to sync product settings, continuing with metrics calculations:', syncError); + // Don't fail the entire process if settings sync fails + } + + // Track completed steps + const completedSteps = []; + const stepTimings = {}; + const stepRowCounts = {}; + let currentStepIndex = 0; + + // Now run the calculation steps + for (const step of stepsToRun) { + if (isCancelled) { + console.log(`Skipping step "${step.name}" due to cancellation.`); + overallSuccess = false; // Mark as not fully successful if steps are skipped due to cancel + continue; // Skip to next step + } + + currentStepIndex++; + + // Update overall progress + progressUtils.outputProgress({ + status: 'running', + operation: 'Running calculations', + message: `Step ${currentStepIndex} of ${stepsToRun.length}: ${step.name}`, + current: currentStepIndex - 1, + total: stepsToRun.length, + elapsed: progressUtils.formatElapsedTime(overallStartTime), + remaining: progressUtils.estimateRemaining(overallStartTime, currentStepIndex - 1, stepsToRun.length), + percentage: Math.round(((currentStepIndex - 1) / stepsToRun.length) * 100).toString(), + timing: { + overall_start_time: new Date(overallStartTime).toISOString(), + current_step: step.name, + completed_steps: completedSteps.length + } + }); + + // Pass the progress utilities to the step executor + const result = await executeSqlStep(step, progressUtils); + + if (result.success) { + completedSteps.push({ + name: step.name, + duration: result.duration, + status: 'completed', + rowsAffected: result.rowsAffected + }); + stepTimings[step.name] = result.duration; + stepRowCounts[step.name] = result.rowsAffected; + } + } + + // If we finished naturally (no errors thrown out) + clearTimeout(mainTimeoutHandle); // Clear the main timeout + + // Update the combined history record on successful completion + if (combinedHistoryId) { + try { + connection = await getConnection(); + const totalDuration = Math.round((Date.now() - overallStartTime) / 1000); + + // Get final processed counts + const processedCounts = await connection.query(` + SELECT + (SELECT COUNT(*) FROM product_metrics WHERE last_calculated >= $1) as processed_products + `, [new Date(overallStartTime)]); + + await connection.query(` + UPDATE calculate_history + SET + end_time = NOW(), + duration_seconds = $1::integer, + status = $2::calculation_status, + additional_info = additional_info || jsonb_build_object( + 'processed_products', $3::integer, + 'completed_steps', $4::jsonb, + 'step_timings', $5::jsonb, + 'step_row_counts', $6::jsonb + ) + WHERE id = $7::integer; + `, [ + totalDuration, + isCancelled ? 'cancelled' : 'completed', + processedCounts.rows[0].processed_products, + JSON.stringify(completedSteps), + JSON.stringify(stepTimings), + JSON.stringify(stepRowCounts), + combinedHistoryId + ]); + + connection.release(); + } catch (historyError) { + console.error('Error updating combined history record on completion:', historyError); + if (connection) connection.release(); + } + } + + if (isCancelled) { + console.log("\n--- Calculation finished with cancellation ---"); + overallSuccess = false; + } else { + console.log("\n--- All enabled calculations finished successfully ---"); + + // Send final completion progress + progressUtils.outputProgress({ + status: 'complete', + operation: 'All calculations completed', + message: `Successfully completed ${completedSteps.length} of ${stepsToRun.length} steps`, + current: stepsToRun.length, + total: stepsToRun.length, + elapsed: progressUtils.formatElapsedTime(overallStartTime), + remaining: '0s', + percentage: '100', + timing: { + overall_start_time: new Date(overallStartTime).toISOString(), + overall_end_time: new Date().toISOString(), + total_duration_seconds: Math.round((Date.now() - overallStartTime) / 1000), + step_timings: stepTimings, + completed_steps: completedSteps.length + } + }); + + progressUtils.clearProgress(); // Clear progress only on full success + } + + } catch (error) { + clearTimeout(mainTimeoutHandle); // Clear the main timeout + console.error("\n--- SCRIPT EXECUTION FAILED ---"); + // Error details were already logged by executeSqlStep or global handlers + overallSuccess = false; + + // Update the combined history record on error + if (combinedHistoryId) { + try { + connection = await getConnection(); + const totalDuration = Math.round((Date.now() - overallStartTime) / 1000); + + await connection.query(` + UPDATE calculate_history + SET + end_time = NOW(), + duration_seconds = $1::integer, + status = $2::calculation_status, + error_message = $3::text + WHERE id = $4::integer; + `, [ + totalDuration, + isCancelled ? 'cancelled' : 'failed', + error.message.substring(0, 1000), + combinedHistoryId + ]); + + connection.release(); + } catch (historyError) { + console.error('Error updating combined history record on error:', historyError); + if (connection) connection.release(); + } + } + } finally { + await closePool(); + console.log(`Total execution time: ${progressUtils.formatElapsedTime(overallStartTime)}`); + process.exit(overallSuccess ? 0 : 1); + } +} + +// --- Script Execution --- +if (require.main === module) { + runAllCalculations(); +} else { + // Export functions if needed as a module (e.g., for testing or API) + module.exports = { + runAllCalculations, + cancelCalculation, + syncSettingsProductTable, + // Expose individual steps if useful, wrapping them slightly + runDailySnapshots: () => executeSqlStep({ name: 'Daily Snapshots Update', sqlFile: 'update_daily_snapshots.sql', historyType: 'daily_snapshots', statusModule: 'daily_snapshots' }, progressUtils), + runProductMetrics: () => executeSqlStep({ name: 'Product Metrics Update', sqlFile: 'update_product_metrics.sql', historyType: 'product_metrics', statusModule: 'product_metrics' }, progressUtils), + runPeriodicMetrics: () => executeSqlStep({ name: 'Periodic Metrics Update', sqlFile: 'update_periodic_metrics.sql', historyType: 'periodic_metrics', statusModule: 'periodic_metrics' }, progressUtils), + runBrandMetrics: () => executeSqlStep({ name: 'Brand Metrics Update', sqlFile: 'calculate_brand_metrics.sql', historyType: 'brand_metrics', statusModule: 'brand_metrics' }, progressUtils), + runVendorMetrics: () => executeSqlStep({ name: 'Vendor Metrics Update', sqlFile: 'calculate_vendor_metrics.sql', historyType: 'vendor_metrics', statusModule: 'vendor_metrics' }, progressUtils), + runCategoryMetrics: () => executeSqlStep({ name: 'Category Metrics Update', sqlFile: 'calculate_category_metrics.sql', historyType: 'category_metrics', statusModule: 'category_metrics' }, progressUtils), + getProgress: progressUtils.getProgress + }; +} \ No newline at end of file diff --git a/inventory-server/scripts/full-reset.js b/inventory-server/scripts/full-reset.js new file mode 100644 index 0000000..96e00fa --- /dev/null +++ b/inventory-server/scripts/full-reset.js @@ -0,0 +1,115 @@ +const path = require('path'); +const { spawn } = require('child_process'); + +function outputProgress(data) { + if (!data.status) { + data = { + status: 'running', + ...data + }; + } + console.log(JSON.stringify(data)); +} + +function runScript(scriptPath) { + return new Promise((resolve, reject) => { + const child = spawn('node', [scriptPath], { + stdio: ['inherit', 'pipe', 'pipe'], + env: { + ...process.env, + PGHOST: process.env.DB_HOST, + PGUSER: process.env.DB_USER, + PGPASSWORD: process.env.DB_PASSWORD, + PGDATABASE: process.env.DB_NAME, + PGPORT: process.env.DB_PORT || '5432' + } + }); + + let output = ''; + + child.stdout.on('data', (data) => { + const lines = data.toString().split('\n'); + lines.filter(line => line.trim()).forEach(line => { + try { + console.log(line); // Pass through the JSON output + output += line + '\n'; + } catch (e) { + console.log(line); // If not JSON, just log it directly + } + }); + }); + + child.stderr.on('data', (data) => { + console.error(data.toString()); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Script ${scriptPath} exited with code ${code}`)); + } else { + resolve(output); + } + }); + + child.on('error', (err) => { + reject(err); + }); + }); +} + +async function fullReset() { + try { + // Step 1: Reset Database + outputProgress({ + operation: 'Starting full reset', + message: 'Step 1/3: Resetting database...' + }); + await runScript(path.join(__dirname, 'reset-db.js')); + outputProgress({ + status: 'complete', + operation: 'Database reset step complete', + message: 'Database reset finished, moving to import...' + }); + + // Step 2: Import from Production + outputProgress({ + operation: 'Starting import', + message: 'Step 2/3: Importing from production...' + }); + await runScript(path.join(__dirname, 'import-from-prod.js')); + outputProgress({ + status: 'complete', + operation: 'Import step complete', + message: 'Import finished, moving to metrics calculation...' + }); + + // Step 3: Calculate Metrics + outputProgress({ + operation: 'Starting metrics calculation', + message: 'Step 3/3: Calculating metrics...' + }); + await runScript(path.join(__dirname, 'calculate-metrics-new.js')); + + // Final completion message + outputProgress({ + status: 'complete', + operation: 'Full reset complete', + message: 'Successfully completed all steps: database reset, import, and metrics calculation' + }); + } catch (error) { + outputProgress({ + status: 'error', + operation: 'Full reset failed', + error: error.message, + stack: error.stack + }); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + fullReset(); +} + +module.exports = fullReset; \ No newline at end of file diff --git a/inventory-server/scripts/full-update.js b/inventory-server/scripts/full-update.js new file mode 100644 index 0000000..378e5ec --- /dev/null +++ b/inventory-server/scripts/full-update.js @@ -0,0 +1,100 @@ +const path = require('path'); +const { spawn } = require('child_process'); + +function outputProgress(data) { + if (!data.status) { + data = { + status: 'running', + ...data + }; + } + console.log(JSON.stringify(data)); +} + +function runScript(scriptPath) { + return new Promise((resolve, reject) => { + const child = spawn('node', [scriptPath], { + stdio: ['inherit', 'pipe', 'pipe'] + }); + + let output = ''; + + child.stdout.on('data', (data) => { + const lines = data.toString().split('\n'); + lines.filter(line => line.trim()).forEach(line => { + try { + console.log(line); // Pass through the JSON output + output += line + '\n'; + } catch (e) { + console.log(line); // If not JSON, just log it directly + } + }); + }); + + child.stderr.on('data', (data) => { + console.error(data.toString()); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Script ${scriptPath} exited with code ${code}`)); + } else { + resolve(output); + } + }); + + child.on('error', (err) => { + reject(err); + }); + }); +} + +async function fullUpdate() { + try { + // Step 1: Import from Production + outputProgress({ + operation: 'Starting full update', + message: 'Step 1/2: Importing from production...' + }); + await runScript(path.join(__dirname, 'import-from-prod.js')); + outputProgress({ + status: 'complete', + operation: 'Import step complete', + message: 'Import finished, moving to metrics calculation...' + }); + + // Step 2: Calculate Metrics + outputProgress({ + operation: 'Starting metrics calculation', + message: 'Step 2/2: Calculating metrics...' + }); + await runScript(path.join(__dirname, 'calculate-metrics-new.js')); + outputProgress({ + status: 'complete', + operation: 'Metrics step complete', + message: 'Metrics calculation finished' + }); + + // Final completion message + outputProgress({ + status: 'complete', + operation: 'Full update complete', + message: 'Successfully completed all steps: import and metrics calculation' + }); + } catch (error) { + outputProgress({ + status: 'error', + operation: 'Full update failed', + error: error.message, + stack: error.stack + }); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + fullUpdate(); +} + +module.exports = fullUpdate; \ No newline at end of file diff --git a/inventory-server/scripts/import-from-prod.js b/inventory-server/scripts/import-from-prod.js new file mode 100644 index 0000000..3c3404c --- /dev/null +++ b/inventory-server/scripts/import-from-prod.js @@ -0,0 +1,352 @@ +const dotenv = require("dotenv"); +const path = require("path"); +const { outputProgress, formatElapsedTime } = require('./metrics-new/utils/progress'); +const { setupConnections, closeConnections } = require('./import/utils'); +const importCategories = require('./import/categories'); +const { importProducts } = require('./import/products'); +const importOrders = require('./import/orders'); +const importPurchaseOrders = require('./import/purchase-orders'); + +dotenv.config({ path: path.join(__dirname, "../.env") }); + +// Constants to control which imports run +const IMPORT_CATEGORIES = true; +const IMPORT_PRODUCTS = true; +const IMPORT_ORDERS = true; +const IMPORT_PURCHASE_ORDERS = true; + +// Add flag for incremental updates +const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false + +// SSH configuration +const sshConfig = { + ssh: { + host: process.env.PROD_SSH_HOST, + port: process.env.PROD_SSH_PORT || 22, + username: process.env.PROD_SSH_USER, + privateKey: process.env.PROD_SSH_KEY_PATH + ? require("fs").readFileSync(process.env.PROD_SSH_KEY_PATH) + : undefined, + compress: true, // Enable SSH compression + }, + prodDbConfig: { + // MySQL config for production + host: process.env.PROD_DB_HOST || "localhost", + user: process.env.PROD_DB_USER, + password: process.env.PROD_DB_PASSWORD, + database: process.env.PROD_DB_NAME, + port: process.env.PROD_DB_PORT || 3306, + timezone: '-05:00', // Production DB always stores times in EST (UTC-5) regardless of DST + }, + localDbConfig: { + // PostgreSQL config for local + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432, + ssl: process.env.DB_SSL === 'true', + connectionTimeoutMillis: 60000, + idleTimeoutMillis: 30000, + max: 10 // connection pool max size + } +}; + +let isImportCancelled = false; + +// Add cancel function +function cancelImport() { + isImportCancelled = true; + outputProgress({ + status: 'cancelled', + operation: 'Import process', + message: 'Import cancelled by user', + current: 0, + total: 0, + elapsed: null, + remaining: null, + rate: 0 + }); +} + +async function main() { + const startTime = Date.now(); + let connections; + let completedSteps = 0; + let importHistoryId; + const totalSteps = [ + IMPORT_CATEGORIES, + IMPORT_PRODUCTS, + IMPORT_ORDERS, + IMPORT_PURCHASE_ORDERS + ].filter(Boolean).length; + + try { + // Initial progress update + outputProgress({ + status: "running", + operation: "Import process", + message: `Initializing SSH tunnel for ${INCREMENTAL_UPDATE ? 'incremental' : 'full'} import...`, + current: completedSteps, + total: totalSteps, + elapsed: formatElapsedTime(startTime) + }); + + connections = await setupConnections(sshConfig); + const { prodConnection, localConnection } = connections; + + if (isImportCancelled) throw new Error("Import cancelled"); + + // Clean up any previously running imports that weren't completed + await localConnection.query(` + UPDATE import_history + SET + status = 'cancelled', + end_time = NOW(), + duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER, + error_message = 'Previous import was not completed properly' + WHERE status = 'running' + `); + + // Create import history record for the overall session + try { + const [historyResult] = await localConnection.query(` + INSERT INTO import_history ( + table_name, + start_time, + is_incremental, + status, + additional_info + ) VALUES ( + 'all_tables', + NOW(), + $1::boolean, + 'running', + jsonb_build_object( + 'categories_enabled', $2::boolean, + 'products_enabled', $3::boolean, + 'orders_enabled', $4::boolean, + 'purchase_orders_enabled', $5::boolean + ) + ) RETURNING id + `, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS]); + importHistoryId = historyResult.rows[0].id; + } catch (error) { + console.error("Error creating import history record:", error); + outputProgress({ + status: "error", + operation: "Import process", + message: "Failed to create import history record", + error: error.message + }); + throw error; + } + + const results = { + categories: null, + products: null, + orders: null, + purchaseOrders: null + }; + + let totalRecordsAdded = 0; + let totalRecordsUpdated = 0; + let totalRecordsDeleted = 0; // Add tracking for deleted records + let totalRecordsSkipped = 0; // Track skipped/filtered records + const stepTimings = {}; + + // Run each import based on constants + if (IMPORT_CATEGORIES) { + const stepStart = Date.now(); + results.categories = await importCategories(prodConnection, localConnection); + stepTimings.categories = Math.round((Date.now() - stepStart) / 1000); + + if (isImportCancelled) throw new Error("Import cancelled"); + completedSteps++; + console.log('Categories import result:', results.categories); + totalRecordsAdded += parseInt(results.categories?.recordsAdded || 0); + totalRecordsUpdated += parseInt(results.categories?.recordsUpdated || 0); + } + + if (IMPORT_PRODUCTS) { + const stepStart = Date.now(); + results.products = await importProducts(prodConnection, localConnection, INCREMENTAL_UPDATE); + stepTimings.products = Math.round((Date.now() - stepStart) / 1000); + + if (isImportCancelled) throw new Error("Import cancelled"); + completedSteps++; + console.log('Products import result:', results.products); + totalRecordsAdded += parseInt(results.products?.recordsAdded || 0); + totalRecordsUpdated += parseInt(results.products?.recordsUpdated || 0); + totalRecordsSkipped += parseInt(results.products?.skippedUnchanged || 0); + } + + if (IMPORT_ORDERS) { + const stepStart = Date.now(); + results.orders = await importOrders(prodConnection, localConnection, INCREMENTAL_UPDATE); + stepTimings.orders = Math.round((Date.now() - stepStart) / 1000); + + if (isImportCancelled) throw new Error("Import cancelled"); + completedSteps++; + console.log('Orders import result:', results.orders); + totalRecordsAdded += parseInt(results.orders?.recordsAdded || 0); + totalRecordsUpdated += parseInt(results.orders?.recordsUpdated || 0); + totalRecordsSkipped += parseInt(results.orders?.totalSkipped || 0); + } + + if (IMPORT_PURCHASE_ORDERS) { + try { + const stepStart = Date.now(); + results.purchaseOrders = await importPurchaseOrders(prodConnection, localConnection, INCREMENTAL_UPDATE); + stepTimings.purchaseOrders = Math.round((Date.now() - stepStart) / 1000); + + if (isImportCancelled) throw new Error("Import cancelled"); + completedSteps++; + console.log('Purchase orders import result:', results.purchaseOrders); + + // Handle potential error status + if (results.purchaseOrders?.status === 'error') { + console.error('Purchase orders import had an error:', results.purchaseOrders.error); + } else { + totalRecordsAdded += parseInt(results.purchaseOrders?.recordsAdded || 0); + totalRecordsUpdated += parseInt(results.purchaseOrders?.recordsUpdated || 0); + totalRecordsDeleted += parseInt(results.purchaseOrders?.recordsDeleted || 0); + } + } catch (error) { + console.error('Error during purchase orders import:', error); + // Continue with other imports, don't fail the whole process + results.purchaseOrders = { + status: 'error', + error: error.message, + recordsAdded: 0, + recordsUpdated: 0 + }; + } + } + + const endTime = Date.now(); + const totalElapsedSeconds = Math.round((endTime - startTime) / 1000); + + // Update import history with final stats + await localConnection.query(` + UPDATE import_history + SET + end_time = NOW(), + duration_seconds = $1, + records_added = $2, + records_updated = $3, + status = 'completed', + additional_info = jsonb_build_object( + 'categories_enabled', $4::boolean, + 'products_enabled', $5::boolean, + 'orders_enabled', $6::boolean, + 'purchase_orders_enabled', $7::boolean, + 'categories_result', COALESCE($8::jsonb, 'null'::jsonb), + 'products_result', COALESCE($9::jsonb, 'null'::jsonb), + 'orders_result', COALESCE($10::jsonb, 'null'::jsonb), + 'purchase_orders_result', COALESCE($11::jsonb, 'null'::jsonb), + 'total_deleted', $12::integer, + 'total_skipped', $13::integer, + 'step_timings', $14::jsonb + ) + WHERE id = $15 + `, [ + totalElapsedSeconds, + parseInt(totalRecordsAdded), + parseInt(totalRecordsUpdated), + IMPORT_CATEGORIES, + IMPORT_PRODUCTS, + IMPORT_ORDERS, + IMPORT_PURCHASE_ORDERS, + JSON.stringify(results.categories), + JSON.stringify(results.products), + JSON.stringify(results.orders), + JSON.stringify(results.purchaseOrders), + totalRecordsDeleted, + totalRecordsSkipped, + JSON.stringify(stepTimings), + importHistoryId + ]); + + outputProgress({ + status: "complete", + operation: "Import process", + message: `${INCREMENTAL_UPDATE ? 'Incremental' : 'Full'} import completed successfully in ${formatElapsedTime(totalElapsedSeconds)}`, + current: completedSteps, + total: totalSteps, + elapsed: formatElapsedTime(startTime), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date(endTime).toISOString(), + elapsed_time: formatElapsedTime(startTime), + elapsed_seconds: totalElapsedSeconds, + total_duration: formatElapsedTime(totalElapsedSeconds) + }, + results + }); + + return results; + } catch (error) { + const endTime = Date.now(); + const totalElapsedSeconds = Math.round((endTime - startTime) / 1000); + + // Update import history with error + if (importHistoryId && connections?.localConnection) { + await connections.localConnection.query(` + UPDATE import_history + SET + end_time = NOW(), + duration_seconds = $1, + status = $2, + error_message = $3 + WHERE id = $4 + `, [totalElapsedSeconds, error.message === "Import cancelled" ? 'cancelled' : 'failed', error.message, importHistoryId]); + } + + console.error("Error during import process:", error); + outputProgress({ + status: error.message === "Import cancelled" ? "cancelled" : "error", + operation: "Import process", + message: error.message === "Import cancelled" + ? `${INCREMENTAL_UPDATE ? 'Incremental' : 'Full'} import cancelled by user after ${formatElapsedTime(totalElapsedSeconds)}` + : `${INCREMENTAL_UPDATE ? 'Incremental' : 'Full'} import failed after ${formatElapsedTime(totalElapsedSeconds)}`, + error: error.message, + current: completedSteps, + total: totalSteps, + elapsed: formatElapsedTime(startTime), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date(endTime).toISOString(), + elapsed_time: formatElapsedTime(startTime), + elapsed_seconds: totalElapsedSeconds, + total_duration: formatElapsedTime(totalElapsedSeconds) + } + }); + throw error; + } finally { + if (connections) { + await closeConnections(connections).catch(err => { + console.error("Error closing connections:", err); + }); + } + } +} + +// Run the import only if this is the main module +if (require.main === module) { + main().then((results) => { + console.log('Import completed successfully:', results); + // Force exit after a small delay to ensure all logs are written + setTimeout(() => process.exit(0), 500); + }).catch((error) => { + console.error("Unhandled error in main process:", error); + // Force exit with error code after a small delay + setTimeout(() => process.exit(1), 500); + }); +} + +// Export the functions needed by the route +module.exports = { + main, + cancelImport, +}; diff --git a/inventory-server/scripts/import/categories.js b/inventory-server/scripts/import/categories.js new file mode 100644 index 0000000..9fc7f5a --- /dev/null +++ b/inventory-server/scripts/import/categories.js @@ -0,0 +1,210 @@ +const { outputProgress, formatElapsedTime } = require('../metrics-new/utils/progress'); + +async function importCategories(prodConnection, localConnection) { + outputProgress({ + operation: "Starting categories import", + status: "running", + }); + + const startTime = Date.now(); + const typeOrder = [10, 20, 11, 21, 12, 13]; + let totalInserted = 0; + let totalUpdated = 0; + let skippedCategories = []; + + try { + // Start a single transaction for the entire import + await localConnection.query('BEGIN'); + + // Temporarily disable the trigger that's causing problems + await localConnection.query('ALTER TABLE categories DISABLE TRIGGER update_categories_updated_at'); + + // Process each type in order with its own savepoint + for (const type of typeOrder) { + try { + // Create a savepoint for this type + await localConnection.query(`SAVEPOINT category_type_${type}`); + + // Production query remains MySQL compatible + const [categories] = await prodConnection.query( + ` + SELECT + pc.cat_id, + pc.name, + pc.type, + CASE + WHEN pc.type IN (10, 20) THEN NULL -- Top level categories should have no parent + WHEN pc.master_cat_id IS NULL THEN NULL + ELSE pc.master_cat_id + END as parent_id, + pc.combined_name as description + FROM product_categories pc + WHERE pc.type = ? + ORDER BY pc.cat_id + `, + [type] + ); + + if (categories.length === 0) { + await localConnection.query(`RELEASE SAVEPOINT category_type_${type}`); + continue; + } + + console.log(`Processing ${categories.length} type ${type} categories`); + + // For types that can have parents (11, 21, 12, 13), we'll proceed directly + // No need to check for parent existence since we process in hierarchical order + let categoriesToInsert = categories; + + if (categoriesToInsert.length === 0) { + console.log(`No valid categories of type ${type} to insert`); + await localConnection.query(`RELEASE SAVEPOINT category_type_${type}`); + continue; + } + + // PostgreSQL upsert query with parameterized values + const values = categoriesToInsert.flatMap((cat) => [ + cat.cat_id, + cat.name, + cat.type, + cat.parent_id, + cat.description, + 'active', + new Date(), + new Date() + ]); + + const placeholders = categoriesToInsert + .map((_, i) => `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})`) + .join(','); + + // Insert categories with ON CONFLICT clause for PostgreSQL + const query = ` + WITH inserted_categories AS ( + INSERT INTO categories ( + cat_id, name, type, parent_id, description, status, created_at, updated_at + ) + VALUES ${placeholders} + ON CONFLICT (cat_id) DO UPDATE SET + name = EXCLUDED.name, + type = EXCLUDED.type, + parent_id = EXCLUDED.parent_id, + description = EXCLUDED.description, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at + WHERE -- Only update if at least one field has changed + categories.name IS DISTINCT FROM EXCLUDED.name OR + categories.type IS DISTINCT FROM EXCLUDED.type OR + categories.parent_id IS DISTINCT FROM EXCLUDED.parent_id OR + categories.description IS DISTINCT FROM EXCLUDED.description OR + categories.status IS DISTINCT FROM EXCLUDED.status + RETURNING + cat_id, + CASE + WHEN xmax = 0 THEN true + ELSE false + END as is_insert + ) + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE is_insert) as inserted, + COUNT(*) FILTER (WHERE NOT is_insert) as updated + FROM inserted_categories`; + + const result = await localConnection.query(query, values); + + // Get the first result since query returns an array + const queryResult = Array.isArray(result) ? result[0] : result; + + if (!queryResult || !queryResult.rows || !queryResult.rows[0]) { + console.error('Query failed to return results'); + throw new Error('Query did not return expected results'); + } + + const total = parseInt(queryResult.rows[0].total) || 0; + const inserted = parseInt(queryResult.rows[0].inserted) || 0; + const updated = parseInt(queryResult.rows[0].updated) || 0; + + console.log(`Total: ${total}, Inserted: ${inserted}, Updated: ${updated}`); + + totalInserted += inserted; + totalUpdated += updated; + + // Release the savepoint for this type + await localConnection.query(`RELEASE SAVEPOINT category_type_${type}`); + + outputProgress({ + status: "running", + operation: "Categories import", + message: `Imported ${inserted} (updated ${updated}) categories of type ${type}`, + current: totalInserted + totalUpdated, + total: categories.length, + elapsed: formatElapsedTime(startTime), + }); + } catch (error) { + // Rollback to the savepoint for this type + await localConnection.query(`ROLLBACK TO SAVEPOINT category_type_${type}`); + throw error; + } + } + + // Commit the entire transaction - we'll do this even if we have skipped categories + await localConnection.query('COMMIT'); + + // Update sync status + await localConnection.query(` + INSERT INTO sync_status (table_name, last_sync_timestamp) + VALUES ('categories', NOW()) + ON CONFLICT (table_name) DO UPDATE SET + last_sync_timestamp = NOW() + `); + + // Re-enable the trigger + await localConnection.query('ALTER TABLE categories ENABLE TRIGGER update_categories_updated_at'); + + outputProgress({ + status: "complete", + operation: "Categories import completed", + current: totalInserted + totalUpdated, + total: totalInserted + totalUpdated, + duration: formatElapsedTime(startTime), + warnings: skippedCategories.length > 0 ? { + message: "Some categories were skipped due to missing parents", + skippedCategories + } : undefined + }); + + return { + status: "complete", + recordsAdded: totalInserted, + recordsUpdated: totalUpdated, + totalRecords: totalInserted + totalUpdated, + warnings: skippedCategories.length > 0 ? { + message: "Some categories were skipped due to missing parents", + skippedCategories + } : undefined + }; + } catch (error) { + console.error("Error importing categories:", error); + + // Only rollback if we haven't committed yet + try { + await localConnection.query('ROLLBACK'); + + // Make sure we re-enable the trigger even if there was an error + await localConnection.query('ALTER TABLE categories ENABLE TRIGGER update_categories_updated_at'); + } catch (rollbackError) { + console.error("Error during rollback:", rollbackError); + } + + outputProgress({ + status: "error", + operation: "Categories import failed", + error: error.message + }); + + throw error; + } +} + +module.exports = importCategories; \ No newline at end of file diff --git a/inventory-server/scripts/import/orders.js b/inventory-server/scripts/import/orders.js new file mode 100644 index 0000000..c1a2450 --- /dev/null +++ b/inventory-server/scripts/import/orders.js @@ -0,0 +1,779 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('../metrics-new/utils/progress'); +const { importMissingProducts, setupTemporaryTables, cleanupTemporaryTables, materializeCalculations } = require('./products'); + +/** + * Imports orders from a production MySQL database to a local PostgreSQL database. + * It can run in two modes: + * 1. Incremental update mode (default): Only fetch orders that have changed since the last sync time. + * 2. Full update mode: Fetch all eligible orders within the last 5 years regardless of timestamp. + * + * @param {object} prodConnection - A MySQL connection to production DB (MySQL 5.7). + * @param {object} localConnection - A MySQL connection to local DB (MySQL 8.0). + * @param {boolean} incrementalUpdate - Set to false for a full sync; true for incremental. + * + * @returns {object} Information about the sync operation. + */ +async function importOrders(prodConnection, localConnection, incrementalUpdate = true) { + const startTime = Date.now(); + const skippedOrders = new Set(); + const missingProducts = new Set(); + let recordsAdded = 0; + let recordsUpdated = 0; + let processedCount = 0; + let importedCount = 0; + let totalOrderItems = 0; + let totalUniqueOrders = 0; + let cumulativeProcessedOrders = 0; + + try { + // Get last sync info - NOT in a transaction anymore + const [syncInfo] = await localConnection.query( + "SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'" + ); + const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01'; + + console.log('Orders: Using last sync time:', lastSyncTime); + + // First get count of order items - Keep MySQL compatible for production + const [[{ total }]] = await prodConnection.query(` + SELECT COUNT(*) as total + FROM order_items oi + JOIN _order o ON oi.order_id = o.order_id + WHERE o.order_status >= 15 + AND o.date_placed >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR) + AND o.date_placed IS NOT NULL + ${incrementalUpdate ? ` + AND ( + o.stamp > ? + OR oi.stamp > ? + OR EXISTS ( + SELECT 1 FROM order_discount_items odi + WHERE odi.order_id = o.order_id + AND odi.pid = oi.prod_pid + ) + OR EXISTS ( + SELECT 1 FROM order_tax_info oti + JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id + WHERE oti.order_id = o.order_id + AND otip.pid = oi.prod_pid + AND oti.stamp > ? + ) + ) + ` : ''} + `, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); + + totalOrderItems = total; + console.log('Orders: Found changes:', totalOrderItems); + + // Get order items - Keep MySQL compatible for production + console.log('Orders: Starting MySQL query...'); + const [orderItems] = await prodConnection.query(` + SELECT + oi.order_id, + oi.prod_pid, + COALESCE(NULLIF(TRIM(oi.prod_itemnumber), ''), 'NO-SKU') as SKU, + oi.prod_price as price, + oi.qty_ordered as quantity, + COALESCE(oi.prod_price_reg - oi.prod_price, 0) as base_discount, + oi.stamp as last_modified + FROM order_items oi + JOIN _order o ON oi.order_id = o.order_id + WHERE o.order_status >= 15 + AND o.date_placed >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR) + AND o.date_placed IS NOT NULL + ${incrementalUpdate ? ` + AND ( + o.stamp > ? + OR oi.stamp > ? + OR EXISTS ( + SELECT 1 FROM order_discount_items odi + WHERE odi.order_id = o.order_id + AND odi.pid = oi.prod_pid + ) + OR EXISTS ( + SELECT 1 FROM order_tax_info oti + JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id + WHERE oti.order_id = o.order_id + AND otip.pid = oi.prod_pid + AND oti.stamp > ? + ) + ) + ` : ''} + `, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); + + console.log('Orders: Found', orderItems.length, 'order items to process'); + + // Create tables in PostgreSQL for data processing + // Start a transaction just for creating the temp tables + await localConnection.beginTransaction(); + try { + await localConnection.query(` + DROP TABLE IF EXISTS temp_order_items; + DROP TABLE IF EXISTS temp_order_meta; + DROP TABLE IF EXISTS temp_order_discounts; + DROP TABLE IF EXISTS temp_order_taxes; + DROP TABLE IF EXISTS temp_order_costs; + DROP TABLE IF EXISTS temp_main_discounts; + DROP TABLE IF EXISTS temp_item_discounts; + + CREATE TEMP TABLE temp_order_items ( + order_id INTEGER NOT NULL, + pid INTEGER NOT NULL, + sku TEXT NOT NULL, + price NUMERIC(14, 4) NOT NULL, + quantity INTEGER NOT NULL, + base_discount NUMERIC(14, 4) DEFAULT 0, + PRIMARY KEY (order_id, pid) + ); + + CREATE TEMP TABLE temp_order_meta ( + order_id INTEGER NOT NULL, + date TIMESTAMP WITH TIME ZONE NOT NULL, + customer TEXT NOT NULL, + customer_name TEXT NOT NULL, + status TEXT, + canceled BOOLEAN, + summary_discount NUMERIC(14, 4) DEFAULT 0.0000, + summary_subtotal NUMERIC(14, 4) DEFAULT 0.0000, + summary_discount_subtotal NUMERIC(14, 4) DEFAULT 0.0000, + PRIMARY KEY (order_id) + ); + + CREATE TEMP TABLE temp_order_discounts ( + order_id INTEGER NOT NULL, + pid INTEGER NOT NULL, + discount NUMERIC(14, 4) NOT NULL, + PRIMARY KEY (order_id, pid) + ); + + CREATE TEMP TABLE temp_main_discounts ( + order_id INTEGER NOT NULL, + discount_id INTEGER NOT NULL, + discount_amount_subtotal NUMERIC(14, 4) DEFAULT 0.0000, + PRIMARY KEY (order_id, discount_id) + ); + + CREATE TEMP TABLE temp_item_discounts ( + order_id INTEGER NOT NULL, + pid INTEGER NOT NULL, + discount_id INTEGER NOT NULL, + amount NUMERIC(14, 4) NOT NULL, + PRIMARY KEY (order_id, pid, discount_id) + ); + + CREATE TEMP TABLE temp_order_taxes ( + order_id INTEGER NOT NULL, + pid INTEGER NOT NULL, + tax NUMERIC(14, 4) NOT NULL, + PRIMARY KEY (order_id, pid) + ); + + CREATE TEMP TABLE temp_order_costs ( + order_id INTEGER NOT NULL, + pid INTEGER NOT NULL, + costeach NUMERIC(14, 4) DEFAULT 0.0000, + PRIMARY KEY (order_id, pid) + ); + + CREATE INDEX idx_temp_order_items_pid ON temp_order_items(pid); + CREATE INDEX idx_temp_order_meta_order_id ON temp_order_meta(order_id); + CREATE INDEX idx_temp_order_discounts_order_pid ON temp_order_discounts(order_id, pid); + CREATE INDEX idx_temp_order_taxes_order_pid ON temp_order_taxes(order_id, pid); + CREATE INDEX idx_temp_order_costs_order_pid ON temp_order_costs(order_id, pid); + CREATE INDEX idx_temp_main_discounts_discount_id ON temp_main_discounts(discount_id); + CREATE INDEX idx_temp_item_discounts_order_pid ON temp_item_discounts(order_id, pid); + CREATE INDEX idx_temp_item_discounts_discount_id ON temp_item_discounts(discount_id); + `); + await localConnection.commit(); + } catch (error) { + await localConnection.rollback(); + throw error; + } + + // Insert order items in batches - each batch gets its own transaction + for (let i = 0; i < orderItems.length; i += 5000) { + await localConnection.beginTransaction(); + try { + const batch = orderItems.slice(i, Math.min(i + 5000, orderItems.length)); + const placeholders = batch.map((_, idx) => + `($${idx * 6 + 1}, $${idx * 6 + 2}, $${idx * 6 + 3}, $${idx * 6 + 4}, $${idx * 6 + 5}, $${idx * 6 + 6})` + ).join(","); + const values = batch.flatMap(item => [ + item.order_id, item.prod_pid, item.SKU, item.price, item.quantity, item.base_discount + ]); + + await localConnection.query(` + INSERT INTO temp_order_items (order_id, pid, sku, price, quantity, base_discount) + VALUES ${placeholders} + ON CONFLICT (order_id, pid) DO UPDATE SET + sku = EXCLUDED.sku, + price = EXCLUDED.price, + quantity = EXCLUDED.quantity, + base_discount = EXCLUDED.base_discount + `, values); + + await localConnection.commit(); + + processedCount = i + batch.length; + outputProgress({ + status: "running", + operation: "Orders import", + message: `Loading order items: ${processedCount} of ${totalOrderItems}`, + current: processedCount, + total: totalOrderItems, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalOrderItems), + rate: calculateRate(startTime, processedCount) + }); + } catch (error) { + await localConnection.rollback(); + throw error; + } + } + + // Get unique order IDs + const orderIds = [...new Set(orderItems.map(item => item.order_id))]; + totalUniqueOrders = orderIds.length; + console.log('Orders: Processing', totalUniqueOrders, 'unique orders'); + + // Reset processed count for order processing phase + processedCount = 0; + + // Process metadata, discounts, taxes, and costs in parallel + const METADATA_BATCH_SIZE = 2000; + const PG_BATCH_SIZE = 200; + + // Add a helper function for title case conversion + function toTitleCase(str) { + if (!str) return ''; + return str.toLowerCase().split(' ').map(word => { + return word.charAt(0).toUpperCase() + word.slice(1); + }).join(' '); + } + + const processMetadataBatch = async (batchIds) => { + const [orders] = await prodConnection.query(` + SELECT + o.order_id, + o.date_placed as date, + o.order_cid as customer, + CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) as customer_name, + o.order_status as status, + CASE WHEN o.date_cancelled != '0000-00-00 00:00:00' THEN 1 ELSE 0 END as canceled, + o.summary_discount, + o.summary_subtotal, + o.summary_discount_subtotal + FROM _order o + LEFT JOIN users u ON o.order_cid = u.cid + WHERE o.order_id IN (?) + `, [batchIds]); + + // Process in sub-batches for PostgreSQL + await localConnection.beginTransaction(); + try { + for (let j = 0; j < orders.length; j += PG_BATCH_SIZE) { + const subBatch = orders.slice(j, j + PG_BATCH_SIZE); + if (subBatch.length === 0) continue; + + const placeholders = subBatch.map((_, idx) => + `($${idx * 9 + 1}, $${idx * 9 + 2}, $${idx * 9 + 3}, $${idx * 9 + 4}, $${idx * 9 + 5}, $${idx * 9 + 6}, $${idx * 9 + 7}, $${idx * 9 + 8}, $${idx * 9 + 9})` + ).join(","); + + const values = subBatch.flatMap(order => [ + order.order_id, + new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE + order.customer, + toTitleCase(order.customer_name) || '', + order.status.toString(), // Convert status to TEXT + order.canceled, + order.summary_discount || 0, + order.summary_subtotal || 0, + order.summary_discount_subtotal || 0 + ]); + + await localConnection.query(` + INSERT INTO temp_order_meta ( + order_id, date, customer, customer_name, status, canceled, + summary_discount, summary_subtotal, summary_discount_subtotal + ) + VALUES ${placeholders} + ON CONFLICT (order_id) DO UPDATE SET + date = EXCLUDED.date, + customer = EXCLUDED.customer, + customer_name = EXCLUDED.customer_name, + status = EXCLUDED.status, + canceled = EXCLUDED.canceled, + summary_discount = EXCLUDED.summary_discount, + summary_subtotal = EXCLUDED.summary_subtotal, + summary_discount_subtotal = EXCLUDED.summary_discount_subtotal + `, values); + } + await localConnection.commit(); + } catch (error) { + await localConnection.rollback(); + throw error; + } + }; + + const processDiscountsBatch = async (batchIds) => { + // First, load main discount records + const [mainDiscounts] = await prodConnection.query(` + SELECT order_id, discount_id, discount_amount_subtotal + FROM order_discounts + WHERE order_id IN (?) + `, [batchIds]); + + if (mainDiscounts.length > 0) { + await localConnection.beginTransaction(); + try { + for (let j = 0; j < mainDiscounts.length; j += PG_BATCH_SIZE) { + const subBatch = mainDiscounts.slice(j, j + PG_BATCH_SIZE); + if (subBatch.length === 0) continue; + + const placeholders = subBatch.map((_, idx) => + `($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})` + ).join(","); + + const values = subBatch.flatMap(d => [ + d.order_id, + d.discount_id, + d.discount_amount_subtotal || 0 + ]); + + await localConnection.query(` + INSERT INTO temp_main_discounts (order_id, discount_id, discount_amount_subtotal) + VALUES ${placeholders} + ON CONFLICT (order_id, discount_id) DO UPDATE SET + discount_amount_subtotal = EXCLUDED.discount_amount_subtotal + `, values); + } + await localConnection.commit(); + } catch (error) { + await localConnection.rollback(); + throw error; + } + } + + // Then, load item discount records + const [discounts] = await prodConnection.query(` + SELECT order_id, pid, discount_id, amount + FROM order_discount_items + WHERE order_id IN (?) + `, [batchIds]); + + if (discounts.length === 0) return; + + // Process in memory to handle potential duplicates + const discountMap = new Map(); + for (const d of discounts) { + const key = `${d.order_id}-${d.pid}-${d.discount_id}`; + discountMap.set(key, d); + } + + const uniqueDiscounts = Array.from(discountMap.values()); + + await localConnection.beginTransaction(); + try { + for (let j = 0; j < uniqueDiscounts.length; j += PG_BATCH_SIZE) { + const subBatch = uniqueDiscounts.slice(j, j + PG_BATCH_SIZE); + if (subBatch.length === 0) continue; + + const placeholders = subBatch.map((_, idx) => + `($${idx * 4 + 1}, $${idx * 4 + 2}, $${idx * 4 + 3}, $${idx * 4 + 4})` + ).join(","); + + const values = subBatch.flatMap(d => [ + d.order_id, + d.pid, + d.discount_id, + d.amount || 0 + ]); + + await localConnection.query(` + INSERT INTO temp_item_discounts (order_id, pid, discount_id, amount) + VALUES ${placeholders} + ON CONFLICT (order_id, pid, discount_id) DO UPDATE SET + amount = EXCLUDED.amount + `, values); + } + + // Create aggregated view with a simpler, safer query that avoids duplicates + await localConnection.query(` + TRUNCATE temp_order_discounts; + + INSERT INTO temp_order_discounts (order_id, pid, discount) + SELECT order_id, pid, SUM(amount) as discount + FROM temp_item_discounts + GROUP BY order_id, pid + `); + + await localConnection.commit(); + } catch (error) { + await localConnection.rollback(); + throw error; + } + }; + + const processTaxesBatch = async (batchIds) => { + // Optimized tax query to avoid subquery + const [taxes] = await prodConnection.query(` + SELECT oti.order_id, otip.pid, otip.item_taxes_to_collect as tax + FROM ( + SELECT order_id, MAX(taxinfo_id) as latest_taxinfo_id + FROM order_tax_info + WHERE order_id IN (?) + GROUP BY order_id + ) latest_info + JOIN order_tax_info oti ON oti.order_id = latest_info.order_id + AND oti.taxinfo_id = latest_info.latest_taxinfo_id + JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id + `, [batchIds]); + + if (taxes.length === 0) return; + + await localConnection.beginTransaction(); + try { + for (let j = 0; j < taxes.length; j += PG_BATCH_SIZE) { + const subBatch = taxes.slice(j, j + PG_BATCH_SIZE); + if (subBatch.length === 0) continue; + + const placeholders = subBatch.map((_, idx) => + `($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})` + ).join(","); + + const values = subBatch.flatMap(t => [ + t.order_id, + t.pid, + t.tax || 0 + ]); + + await localConnection.query(` + INSERT INTO temp_order_taxes (order_id, pid, tax) + VALUES ${placeholders} + ON CONFLICT (order_id, pid) DO UPDATE SET + tax = EXCLUDED.tax + `, values); + } + await localConnection.commit(); + } catch (error) { + await localConnection.rollback(); + throw error; + } + }; + + const processCostsBatch = async (batchIds) => { + // Modified query to ensure one row per order_id/pid by using a subquery + const [costs] = await prodConnection.query(` + SELECT + oc.orderid as order_id, + oc.pid, + oc.costeach + FROM order_costs oc + INNER JOIN ( + SELECT + orderid, + pid, + MAX(id) as max_id + FROM order_costs + WHERE orderid IN (?) + AND pending = 0 + GROUP BY orderid, pid + ) latest ON oc.orderid = latest.orderid AND oc.pid = latest.pid AND oc.id = latest.max_id + `, [batchIds]); + + if (costs.length === 0) return; + + await localConnection.beginTransaction(); + try { + for (let j = 0; j < costs.length; j += PG_BATCH_SIZE) { + const subBatch = costs.slice(j, j + PG_BATCH_SIZE); + if (subBatch.length === 0) continue; + + const placeholders = subBatch.map((_, idx) => + `($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})` + ).join(","); + + const values = subBatch.flatMap(c => [ + c.order_id, + c.pid, + c.costeach || 0 + ]); + + await localConnection.query(` + INSERT INTO temp_order_costs (order_id, pid, costeach) + VALUES ${placeholders} + ON CONFLICT (order_id, pid) DO UPDATE SET + costeach = EXCLUDED.costeach + `, values); + } + await localConnection.commit(); + } catch (error) { + await localConnection.rollback(); + throw error; + } + }; + + // Process all data types SEQUENTIALLY for each batch - not in parallel + for (let i = 0; i < orderIds.length; i += METADATA_BATCH_SIZE) { + const batchIds = orderIds.slice(i, i + METADATA_BATCH_SIZE); + + // Run these sequentially instead of in parallel to avoid transaction conflicts + await processMetadataBatch(batchIds); + await processDiscountsBatch(batchIds); + await processTaxesBatch(batchIds); + await processCostsBatch(batchIds); + + processedCount = i + batchIds.length; + outputProgress({ + status: "running", + operation: "Orders import", + message: `Loading order data: ${processedCount} of ${totalUniqueOrders}`, + current: processedCount, + total: totalUniqueOrders, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalUniqueOrders), + rate: calculateRate(startTime, processedCount) + }); + } + + // Pre-check all products at once + const allOrderPids = [...new Set(orderItems.map(item => item.prod_pid))]; + console.log('Orders: Checking', allOrderPids.length, 'unique products'); + + const [existingProducts] = allOrderPids.length > 0 ? await localConnection.query( + "SELECT pid FROM products WHERE pid = ANY($1::bigint[])", + [allOrderPids] + ) : [[]]; + + const existingPids = new Set(existingProducts.rows.map(p => p.pid)); + + // Process in smaller batches + for (let i = 0; i < orderIds.length; i += 2000) { // Increased from 1000 to 2000 + const batchIds = orderIds.slice(i, i + 2000); + + // Get combined data for this batch in sub-batches + const PG_BATCH_SIZE = 200; // Increased from 100 to 200 + for (let j = 0; j < batchIds.length; j += PG_BATCH_SIZE) { + const subBatchIds = batchIds.slice(j, j + PG_BATCH_SIZE); + + // Start a transaction for this sub-batch + await localConnection.beginTransaction(); + try { + const [orders] = await localConnection.query(` + WITH order_totals AS ( + SELECT + oi.order_id, + oi.pid, + -- Instead of using ARRAY_AGG which can cause duplicate issues, use SUM with a CASE + SUM(CASE + WHEN COALESCE(md.discount_amount_subtotal, 0) > 0 THEN id.amount + ELSE 0 + END) as promo_discount_sum, + COALESCE(ot.tax, 0) as total_tax, + COALESCE(oc.costeach, oi.price * 0.5) as costeach + FROM temp_order_items oi + LEFT JOIN temp_item_discounts id ON oi.order_id = id.order_id AND oi.pid = id.pid + LEFT JOIN temp_main_discounts md ON id.order_id = md.order_id AND id.discount_id = md.discount_id + LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid + LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid + WHERE oi.order_id = ANY($1) + GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach + ) + SELECT + oi.order_id as order_number, + oi.pid::bigint as pid, + oi.sku, + om.date, + oi.price, + oi.quantity, + ( + -- Part 1: Sale Savings for the Line + (oi.base_discount * oi.quantity) + + + -- Part 2: Prorated Points Discount (if applicable) + CASE + WHEN om.summary_discount_subtotal > 0 AND om.summary_subtotal > 0 THEN + COALESCE(ROUND((om.summary_discount_subtotal * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 4), 0) + ELSE 0 + END + + + -- Part 3: Specific Item-Level Discount (only if parent discount affected subtotal) + COALESCE(ot.promo_discount_sum, 0) + )::NUMERIC(14, 4) as discount, + COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax, + false as tax_included, + 0 as shipping, + om.customer, + om.customer_name, + om.status, + om.canceled, + COALESCE(ot.costeach, oi.price * 0.5)::NUMERIC(14, 4) as costeach + FROM temp_order_items oi + JOIN temp_order_meta om ON oi.order_id = om.order_id + LEFT JOIN order_totals ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid + WHERE oi.order_id = ANY($1) + ORDER BY oi.order_id, oi.pid + `, [subBatchIds]); + + // Filter orders and track missing products + const validOrders = []; + const processedOrderItems = new Set(); + const processedOrders = new Set(); + + for (const order of orders.rows) { + if (!existingPids.has(order.pid)) { + missingProducts.add(order.pid); + skippedOrders.add(order.order_number); + continue; + } + validOrders.push(order); + processedOrderItems.add(`${order.order_number}-${order.pid}`); + processedOrders.add(order.order_number); + } + + // Process valid orders in smaller sub-batches + const FINAL_BATCH_SIZE = 100; // Increased from 50 to 100 + for (let k = 0; k < validOrders.length; k += FINAL_BATCH_SIZE) { + const subBatch = validOrders.slice(k, k + FINAL_BATCH_SIZE); + + const placeholders = subBatch.map((_, idx) => { + const base = idx * 15; // 15 columns including costeach + return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`; + }).join(','); + + const batchValues = subBatch.flatMap(o => [ + o.order_number, + o.pid, + o.sku || 'NO-SKU', + o.date, // This is now a TIMESTAMP WITH TIME ZONE + o.price, + o.quantity, + o.discount, + o.tax, + o.tax_included, + o.shipping, + o.customer, + o.customer_name, + o.status.toString(), // Convert status to TEXT + o.canceled, + o.costeach + ]); + + const [result] = await localConnection.query(` + WITH inserted_orders AS ( + INSERT INTO orders ( + order_number, pid, sku, date, price, quantity, discount, + tax, tax_included, shipping, customer, customer_name, + status, canceled, costeach + ) + VALUES ${placeholders} + ON CONFLICT (order_number, pid) DO UPDATE SET + sku = EXCLUDED.sku, + date = EXCLUDED.date, + price = EXCLUDED.price, + quantity = EXCLUDED.quantity, + discount = EXCLUDED.discount, + tax = EXCLUDED.tax, + tax_included = EXCLUDED.tax_included, + shipping = EXCLUDED.shipping, + customer = EXCLUDED.customer, + customer_name = EXCLUDED.customer_name, + status = EXCLUDED.status, + canceled = EXCLUDED.canceled, + costeach = EXCLUDED.costeach + WHERE -- Only update if at least one key field has changed + orders.price IS DISTINCT FROM EXCLUDED.price OR + orders.quantity IS DISTINCT FROM EXCLUDED.quantity OR + orders.discount IS DISTINCT FROM EXCLUDED.discount OR + orders.tax IS DISTINCT FROM EXCLUDED.tax OR + orders.status IS DISTINCT FROM EXCLUDED.status OR + orders.canceled IS DISTINCT FROM EXCLUDED.canceled OR + orders.costeach IS DISTINCT FROM EXCLUDED.costeach OR + orders.date IS DISTINCT FROM EXCLUDED.date + RETURNING xmax = 0 as inserted + ) + SELECT + COUNT(*) FILTER (WHERE inserted) as inserted, + COUNT(*) FILTER (WHERE NOT inserted) as updated + FROM inserted_orders + `, batchValues); + + const { inserted, updated } = result.rows[0]; + recordsAdded += parseInt(inserted) || 0; + recordsUpdated += parseInt(updated) || 0; + importedCount += subBatch.length; + } + + await localConnection.commit(); + + cumulativeProcessedOrders += processedOrders.size; + outputProgress({ + status: "running", + operation: "Orders import", + message: `Importing orders: ${cumulativeProcessedOrders} of ${totalUniqueOrders}`, + current: cumulativeProcessedOrders, + total: totalUniqueOrders, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, cumulativeProcessedOrders, totalUniqueOrders), + rate: calculateRate(startTime, cumulativeProcessedOrders) + }); + } catch (error) { + await localConnection.rollback(); + throw error; + } + } + } + + // Start a transaction for updating sync status and dropping temp tables + await localConnection.beginTransaction(); + try { + // Update sync status + await localConnection.query(` + INSERT INTO sync_status (table_name, last_sync_timestamp) + VALUES ('orders', NOW()) + ON CONFLICT (table_name) DO UPDATE SET + last_sync_timestamp = NOW() + `); + + // Cleanup temporary tables + await localConnection.query(` + DROP TABLE IF EXISTS temp_order_items; + DROP TABLE IF EXISTS temp_order_meta; + DROP TABLE IF EXISTS temp_order_discounts; + DROP TABLE IF EXISTS temp_order_taxes; + DROP TABLE IF EXISTS temp_order_costs; + DROP TABLE IF EXISTS temp_main_discounts; + DROP TABLE IF EXISTS temp_item_discounts; + `); + + // Commit final transaction + await localConnection.commit(); + } catch (error) { + await localConnection.rollback(); + throw error; + } + + return { + status: "complete", + totalImported: Math.floor(importedCount) || 0, + recordsAdded: parseInt(recordsAdded) || 0, + recordsUpdated: parseInt(recordsUpdated) || 0, + totalSkipped: skippedOrders.size || 0, + missingProducts: missingProducts.size || 0, + totalProcessed: orderItems.length, // Total order items in source + incrementalUpdate, + lastSyncTime, + details: { + uniqueOrdersProcessed: cumulativeProcessedOrders, + totalOrderItems: orderItems.length, + skippedDueToMissingProducts: skippedOrders.size, + missingProductIds: Array.from(missingProducts).slice(0, 100) // First 100 for debugging + } + }; + } catch (error) { + console.error("Error during orders import:", error); + throw error; + } +} + +module.exports = importOrders; \ No newline at end of file diff --git a/inventory-server/scripts/import/products.js b/inventory-server/scripts/import/products.js new file mode 100644 index 0000000..00ddd5c --- /dev/null +++ b/inventory-server/scripts/import/products.js @@ -0,0 +1,950 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('../metrics-new/utils/progress'); +const BATCH_SIZE = 1000; // Smaller batch size for better progress tracking +const MAX_RETRIES = 3; +const RETRY_DELAY = 5000; // 5 seconds +const dotenv = require("dotenv"); +const path = require("path"); +dotenv.config({ path: path.join(__dirname, "../../.env") }); + +// Utility functions +const imageUrlBase = process.env.PRODUCT_IMAGE_URL_BASE || 'https://sbing.com/i/products/0000/'; +const getImageUrls = (pid, iid = 1) => { + const paddedPid = pid.toString().padStart(6, '0'); + // Use padded PID only for the first 3 digits + const prefix = paddedPid.slice(0, 3); + // Use the actual pid for the rest of the URL + const basePath = `${imageUrlBase}${prefix}/${pid}`; + return { + image: `${basePath}-t-${iid}.jpg`, + image_175: `${basePath}-175x175-${iid}.jpg`, + image_full: `${basePath}-o-${iid}.jpg` + }; +}; + +// Add helper function for retrying operations with exponential backoff +async function withRetry(operation, errorMessage) { + let lastError; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + console.error(`${errorMessage} (Attempt ${attempt}/${MAX_RETRIES}):`, error); + if (attempt < MAX_RETRIES) { + const backoffTime = RETRY_DELAY * Math.pow(2, attempt - 1); + await new Promise(resolve => setTimeout(resolve, backoffTime)); + } + } + } + throw lastError; +} + +// Add helper function at the top of the file +function validateDate(mysqlDate) { + if (!mysqlDate || mysqlDate === '0000-00-00' || mysqlDate === '0000-00-00 00:00:00') { + return null; + } + // Check if the date is valid + const date = new Date(mysqlDate); + return isNaN(date.getTime()) ? null : mysqlDate; +} + +async function setupTemporaryTables(connection) { + // Drop the table if it exists + await connection.query('DROP TABLE IF EXISTS temp_products'); + + // Create the temporary table + await connection.query(` + CREATE TEMP TABLE temp_products ( + pid BIGINT NOT NULL, + title TEXT, + description TEXT, + sku TEXT, + stock_quantity INTEGER DEFAULT 0, + preorder_count INTEGER DEFAULT 0, + notions_inv_count INTEGER DEFAULT 0, + price NUMERIC(14, 4) NOT NULL DEFAULT 0, + regular_price NUMERIC(14, 4) NOT NULL DEFAULT 0, + cost_price NUMERIC(14, 4), + vendor TEXT, + vendor_reference TEXT, + notions_reference TEXT, + brand TEXT, + line TEXT, + subline TEXT, + artist TEXT, + categories TEXT, + created_at TIMESTAMP WITH TIME ZONE, + first_received TIMESTAMP WITH TIME ZONE, + landing_cost_price NUMERIC(14, 4), + barcode TEXT, + harmonized_tariff_code TEXT, + updated_at TIMESTAMP WITH TIME ZONE, + visible BOOLEAN, + managing_stock BOOLEAN DEFAULT true, + replenishable BOOLEAN, + permalink TEXT, + moq INTEGER DEFAULT 1, + uom INTEGER DEFAULT 1, + rating NUMERIC(14, 4), + reviews INTEGER, + weight NUMERIC(14, 4), + length NUMERIC(14, 4), + width NUMERIC(14, 4), + height NUMERIC(14, 4), + country_of_origin TEXT, + location TEXT, + total_sold INTEGER, + baskets INTEGER, + notifies INTEGER, + date_last_sold TIMESTAMP WITH TIME ZONE, + primary_iid INTEGER, + image TEXT, + image_175 TEXT, + image_full TEXT, + options TEXT, + tags TEXT, + needs_update BOOLEAN DEFAULT TRUE, + PRIMARY KEY (pid) + )`); + + // Create the index + await connection.query('CREATE INDEX idx_temp_products_needs_update ON temp_products (needs_update)'); +} + +async function cleanupTemporaryTables(connection) { + await connection.query('DROP TABLE IF EXISTS temp_products'); +} + +async function importMissingProducts(prodConnection, localConnection, missingPids) { + if (!missingPids || missingPids.length === 0) { + return { + status: "complete", + recordsAdded: 0, + message: "No missing products to import" + }; + } + + try { + // Setup temporary tables + await setupTemporaryTables(localConnection); + + // Get product data from production - Keep MySQL compatible + const [prodData] = await prodConnection.query(` + SELECT + p.pid, + p.description AS title, + p.notes AS description, + p.itemnumber AS sku, + p.date_created, + p.datein AS first_received, + p.location, + p.upc AS barcode, + p.harmonized_tariff_code, + p.stamp AS updated_at, + CASE WHEN si.show + si.buyable > 0 THEN 1 ELSE 0 END AS visible, + CASE + WHEN p.reorder < 0 THEN 0 + WHEN p.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR) THEN 1 + WHEN COALESCE(pnb.inventory, 0) > 0 THEN 1 + WHEN ( + (COALESCE(pls.date_sold, '0000-00-00') = '0000-00-00' OR pls.date_sold <= DATE_SUB(CURRENT_DATE, INTERVAL 5 YEAR)) + AND (p.datein = '0000-00-00 00:00:00' OR p.datein <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) + AND (p.date_refill = '0000-00-00 00:00:00' OR p.date_refill <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) + ) THEN 0 + ELSE 1 + END AS replenishable, + COALESCE(si.available_local, 0) as stock_quantity, + 0 as pending_qty, + COALESCE(ci.onpreorder, 0) as preorder_count, + COALESCE(pnb.inventory, 0) as notions_inv_count, + COALESCE(pcp.price_each, 0) as price, + COALESCE(p.sellingprice, 0) AS regular_price, + CASE + WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) + THEN ( + SELECT ROUND(SUM(costeach * count) / SUM(count), 5) + FROM product_inventory + WHERE pid = p.pid AND count > 0 + ) + ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) + END AS cost_price, + NULL as landing_cost_price, + s.companyname AS vendor, + CASE + WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber + ELSE sid.supplier_itemnumber + END AS vendor_reference, + sid.notions_itemnumber AS notions_reference, + CONCAT('https://www.acherryontop.com/shop/product/', p.pid) AS permalink, + pc1.name AS brand, + pc2.name AS line, + pc3.name AS subline, + pc4.name AS artist, + COALESCE(CASE + WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit + ELSE sid.supplier_qty_per_unit + END, sid.notions_qty_per_unit) AS moq, + p.rating, + p.rating_votes AS reviews, + p.weight, + p.length, + p.width, + p.height, + p.country_of_origin, + (SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets, + (SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies, + (SELECT COALESCE(SUM(oi.qty_ordered), 0) + FROM order_items oi + JOIN _order o ON oi.order_id = o.order_id + WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold, + pls.date_sold as date_last_sold, + (SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid, + GROUP_CONCAT(DISTINCT CASE + WHEN pc.cat_id IS NOT NULL + AND pc.type IN (10, 20, 11, 21, 12, 13) + AND pci.cat_id NOT IN (16, 17) + THEN pci.cat_id + END) as category_ids + FROM products p + LEFT JOIN shop_inventory si ON p.pid = si.pid AND si.store = 0 + LEFT JOIN current_inventory ci ON p.pid = ci.pid + LEFT JOIN product_notions_b2b pnb ON p.pid = pnb.pid + LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1 + LEFT JOIN supplier_item_data sid ON p.pid = sid.pid + LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid + LEFT JOIN product_category_index pci ON p.pid = pci.pid + LEFT JOIN product_categories pc ON pci.cat_id = pc.cat_id + LEFT JOIN product_categories pc1 ON p.company = pc1.cat_id + LEFT JOIN product_categories pc2 ON p.line = pc2.cat_id + LEFT JOIN product_categories pc3 ON p.subline = pc3.cat_id + LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id + LEFT JOIN product_last_sold pls ON p.pid = pls.pid + WHERE p.pid IN (?) + GROUP BY p.pid + `, [missingPids]); + + if (!prodData || prodData.length === 0) { + return { + status: "complete", + recordsAdded: 0, + message: "No products found in production database" + }; + } + + // Process in batches + let recordsAdded = 0; + for (let i = 0; i < prodData.length; i += BATCH_SIZE) { + const batch = prodData.slice(i, i + BATCH_SIZE); + + const placeholders = batch.map((_, idx) => { + const base = idx * 48; // 48 columns + return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`; + }).join(','); + + const values = batch.flatMap(row => { + const imageUrls = getImageUrls(row.pid, row.primary_iid || 1); + return [ + row.pid, + row.title, + row.description, + row.sku || '', + row.stock_quantity > 5000 ? 0 : Math.max(0, row.stock_quantity), + row.preorder_count, + row.notions_inv_count, + row.price, + row.regular_price, + row.cost_price, + row.vendor, + row.vendor_reference, + row.notions_reference, + row.brand, + row.line, + row.subline, + row.artist, + row.category_ids, + validateDate(row.date_created), + validateDate(row.first_received), + row.landing_cost_price, + row.barcode, + row.harmonized_tariff_code, + validateDate(row.updated_at), + row.visible, + true, + row.replenishable, + row.permalink, + Math.max(1, Math.round(row.moq || 1)), + 1, + row.rating, + row.reviews, + row.weight, + row.length, + row.width, + row.height, + row.country_of_origin, + row.location, + row.total_sold, + row.baskets, + row.notifies, + validateDate(row.date_last_sold), + row.primary_iid, + imageUrls.image, + imageUrls.image_175, + imageUrls.image_full, + null, + null + ]; + }); + + const [result] = await localConnection.query(` + WITH inserted_products AS ( + INSERT INTO products ( + pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, + price, regular_price, cost_price, vendor, vendor_reference, notions_reference, + brand, line, subline, artist, categories, created_at, first_received, + landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, + managing_stock, replenishable, permalink, moq, uom, rating, reviews, + weight, length, width, height, country_of_origin, location, total_sold, + baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags + ) + VALUES ${placeholders} + ON CONFLICT (pid) DO NOTHING + RETURNING pid + ) + SELECT COUNT(*) as inserted FROM inserted_products + `, values); + + recordsAdded += parseInt(result.rows[0].inserted, 10) || 0; + } + + return { + status: "complete", + recordsAdded, + message: `Successfully imported ${recordsAdded} missing products` + }; + } catch (error) { + console.error('Error importing missing products:', error); + throw error; + } +} + +async function materializeCalculations(prodConnection, localConnection, incrementalUpdate = true, lastSyncTime = '1970-01-01', startTime = Date.now()) { + outputProgress({ + status: "running", + operation: "Products import", + message: "Fetching product data from production" + }); + + // Get all product data in a single optimized query - Keep MySQL compatible + const [prodData] = await prodConnection.query(` + SELECT + p.pid, + p.description AS title, + p.notes AS description, + p.itemnumber AS sku, + p.date_created, + p.datein AS first_received, + p.location, + p.upc AS barcode, + p.harmonized_tariff_code, + p.stamp AS updated_at, + CASE WHEN si.show + si.buyable > 0 THEN 1 ELSE 0 END AS visible, + CASE + WHEN p.reorder < 0 THEN 0 + WHEN p.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR) THEN 1 + WHEN COALESCE(pnb.inventory, 0) > 0 THEN 1 + WHEN ( + (COALESCE(pls.date_sold, '0000-00-00') = '0000-00-00' OR pls.date_sold <= DATE_SUB(CURRENT_DATE, INTERVAL 5 YEAR)) + AND (p.datein = '0000-00-00 00:00:00' OR p.datein <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) + AND (p.date_refill = '0000-00-00 00:00:00' OR p.date_refill <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) + ) THEN 0 + ELSE 1 + END AS replenishable, + COALESCE(si.available_local, 0) as stock_quantity, + 0 as pending_qty, + COALESCE(ci.onpreorder, 0) as preorder_count, + COALESCE(pnb.inventory, 0) as notions_inv_count, + COALESCE(pcp.price_each, 0) as price, + COALESCE(p.sellingprice, 0) AS regular_price, + CASE + WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) + THEN ( + SELECT ROUND(SUM(costeach * count) / SUM(count), 5) + FROM product_inventory + WHERE pid = p.pid AND count > 0 + ) + ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) + END AS cost_price, + NULL as landing_cost_price, + s.companyname AS vendor, + CASE + WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber + ELSE sid.supplier_itemnumber + END AS vendor_reference, + sid.notions_itemnumber AS notions_reference, + CONCAT('https://www.acherryontop.com/shop/product/', p.pid) AS permalink, + pc1.name AS brand, + pc2.name AS line, + pc3.name AS subline, + pc4.name AS artist, + COALESCE(CASE + WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit + ELSE sid.supplier_qty_per_unit + END, sid.notions_qty_per_unit) AS moq, + p.rating, + p.rating_votes AS reviews, + p.weight, + p.length, + p.width, + p.height, + p.country_of_origin, + (SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets, + (SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies, + (SELECT COALESCE(SUM(oi.qty_ordered), 0) + FROM order_items oi + JOIN _order o ON oi.order_id = o.order_id + WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold, + pls.date_sold as date_last_sold, + (SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid, + GROUP_CONCAT(DISTINCT CASE + WHEN pc.cat_id IS NOT NULL + AND pc.type IN (10, 20, 11, 21, 12, 13) + AND pci.cat_id NOT IN (16, 17) + THEN pci.cat_id + END) as category_ids + FROM products p + LEFT JOIN shop_inventory si ON p.pid = si.pid AND si.store = 0 + LEFT JOIN current_inventory ci ON p.pid = ci.pid + LEFT JOIN product_notions_b2b pnb ON p.pid = pnb.pid + LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1 + LEFT JOIN supplier_item_data sid ON p.pid = sid.pid + LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid + LEFT JOIN product_category_index pci ON p.pid = pci.pid + LEFT JOIN product_categories pc ON pci.cat_id = pc.cat_id + LEFT JOIN product_categories pc1 ON p.company = pc1.cat_id + LEFT JOIN product_categories pc2 ON p.line = pc2.cat_id + LEFT JOIN product_categories pc3 ON p.subline = pc3.cat_id + LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id + LEFT JOIN product_last_sold pls ON p.pid = pls.pid + WHERE ${incrementalUpdate ? ` + p.stamp > ? OR + ci.stamp > ? OR + pcp.date_deactive > ? OR + pcp.date_active > ? OR + pnb.date_updated > ? + -- Add condition for product_images changes if needed for incremental updates + -- OR EXISTS (SELECT 1 FROM product_images pi WHERE pi.pid = p.pid AND pi.stamp > ?) + ` : 'TRUE'} + GROUP BY p.pid + `, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime /*, lastSyncTime */] : []); + + outputProgress({ + status: "running", + operation: "Products import", + message: `Processing ${prodData.length} product records` + }); + + // Insert all product data into temp table in batches + for (let i = 0; i < prodData.length; i += BATCH_SIZE) { + const batch = prodData.slice(i, Math.min(i + BATCH_SIZE, prodData.length)); + + await withRetry(async () => { + const placeholders = batch.map((_, idx) => { + const base = idx * 48; // 48 columns + return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`; + }).join(','); + + const values = batch.flatMap(row => { + const imageUrls = getImageUrls(row.pid, row.primary_iid || 1); + return [ + row.pid, + row.title, + row.description, + row.sku || '', + row.stock_quantity > 5000 ? 0 : Math.max(0, row.stock_quantity), + row.preorder_count, + row.notions_inv_count, + row.price, + row.regular_price, + row.cost_price, + row.vendor, + row.vendor_reference, + row.notions_reference, + row.brand, + row.line, + row.subline, + row.artist, + row.category_ids, + validateDate(row.date_created), + validateDate(row.first_received), + row.landing_cost_price, + row.barcode, + row.harmonized_tariff_code, + validateDate(row.updated_at), + row.visible, + true, + row.replenishable, + row.permalink, + Math.max(1, Math.round(row.moq || 1)), + 1, + row.rating, + row.reviews, + row.weight, + row.length, + row.width, + row.height, + row.country_of_origin, + row.location, + row.total_sold, + row.baskets, + row.notifies, + validateDate(row.date_last_sold), + row.primary_iid, + imageUrls.image, + imageUrls.image_175, + imageUrls.image_full, + null, + null + ]; + }); + + await localConnection.query(` + INSERT INTO temp_products ( + pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, + price, regular_price, cost_price, vendor, vendor_reference, notions_reference, + brand, line, subline, artist, categories, created_at, first_received, + landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, + managing_stock, replenishable, permalink, moq, uom, rating, reviews, + weight, length, width, height, country_of_origin, location, total_sold, + baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags + ) VALUES ${placeholders} + ON CONFLICT (pid) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + sku = EXCLUDED.sku, + stock_quantity = EXCLUDED.stock_quantity, + preorder_count = EXCLUDED.preorder_count, + notions_inv_count = EXCLUDED.notions_inv_count, + price = EXCLUDED.price, + regular_price = EXCLUDED.regular_price, + cost_price = EXCLUDED.cost_price, + vendor = EXCLUDED.vendor, + vendor_reference = EXCLUDED.vendor_reference, + notions_reference = EXCLUDED.notions_reference, + brand = EXCLUDED.brand, + line = EXCLUDED.line, + subline = EXCLUDED.subline, + artist = EXCLUDED.artist, + created_at = EXCLUDED.created_at, + first_received = EXCLUDED.first_received, + landing_cost_price = EXCLUDED.landing_cost_price, + barcode = EXCLUDED.barcode, + harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, + updated_at = EXCLUDED.updated_at, + visible = EXCLUDED.visible, + managing_stock = EXCLUDED.managing_stock, + replenishable = EXCLUDED.replenishable, + permalink = EXCLUDED.permalink, + moq = EXCLUDED.moq, + uom = EXCLUDED.uom, + rating = EXCLUDED.rating, + reviews = EXCLUDED.reviews, + weight = EXCLUDED.weight, + length = EXCLUDED.length, + width = EXCLUDED.width, + height = EXCLUDED.height, + country_of_origin = EXCLUDED.country_of_origin, + location = EXCLUDED.location, + total_sold = EXCLUDED.total_sold, + baskets = EXCLUDED.baskets, + notifies = EXCLUDED.notifies, + date_last_sold = EXCLUDED.date_last_sold, + primary_iid = EXCLUDED.primary_iid, + image = EXCLUDED.image, + image_175 = EXCLUDED.image_175, + image_full = EXCLUDED.image_full, + options = EXCLUDED.options, + tags = EXCLUDED.tags + RETURNING + xmax = 0 as inserted + `, values); + }, `Error inserting batch ${i} to ${i + batch.length}`); + + outputProgress({ + status: "running", + operation: "Products import", + message: `Imported ${i + batch.length} of ${prodData.length} products`, + current: i + batch.length, + total: prodData.length, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, i + batch.length, prodData.length), + rate: calculateRate(startTime, i + batch.length) + }); + } + + outputProgress({ + status: "running", + operation: "Products import", + message: "Finished materializing calculations" + }); + + // Add step to identify which products actually need updating + outputProgress({ + status: "running", + operation: "Products import", + message: "Identifying changed products" + }); + + // Mark products that haven't changed as needs_update = false + await localConnection.query(` + UPDATE temp_products t + SET needs_update = FALSE + FROM products p + WHERE t.pid = p.pid + AND t.title IS NOT DISTINCT FROM p.title + AND t.description IS NOT DISTINCT FROM p.description + AND t.sku IS NOT DISTINCT FROM p.sku + AND t.stock_quantity = p.stock_quantity + AND t.price = p.price + AND t.regular_price = p.regular_price + AND t.cost_price IS NOT DISTINCT FROM p.cost_price + AND t.vendor IS NOT DISTINCT FROM p.vendor + AND t.brand IS NOT DISTINCT FROM p.brand + AND t.visible = p.visible + AND t.replenishable = p.replenishable + AND t.barcode IS NOT DISTINCT FROM p.barcode + AND t.updated_at IS NOT DISTINCT FROM p.updated_at + AND t.total_sold IS NOT DISTINCT FROM p.total_sold + -- Check key fields that are likely to change + -- We don't need to check every single field, just the important ones + `); + + // Get count of products that need updating + const [countResult] = await localConnection.query(` + SELECT + COUNT(*) FILTER (WHERE needs_update = true) as update_count, + COUNT(*) FILTER (WHERE needs_update = false) as skip_count, + COUNT(*) as total_count + FROM temp_products + `); + + outputProgress({ + status: "running", + operation: "Products import", + message: `Found ${countResult.rows[0].update_count} products that need updating, ${countResult.rows[0].skip_count} unchanged` + }); + + // Return the total products processed + return { + totalProcessed: prodData.length, + needsUpdate: parseInt(countResult.rows[0].update_count), + skipped: parseInt(countResult.rows[0].skip_count) + }; +} + +async function importProducts(prodConnection, localConnection, incrementalUpdate = true) { + const startTime = Date.now(); + let lastSyncTime = '1970-01-01'; + + try { + // Get last sync time if doing incremental update + if (incrementalUpdate) { + const [syncResult] = await localConnection.query( + "SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'products'" + ); + if (syncResult.rows.length > 0) { + lastSyncTime = syncResult.rows[0].last_sync_timestamp; + } + } + + // Start a transaction to ensure temporary tables persist + await localConnection.beginTransaction(); + + try { + // Setup temporary tables + await setupTemporaryTables(localConnection); + + // Materialize calculations into temp table + const materializeResult = await materializeCalculations(prodConnection, localConnection, incrementalUpdate, lastSyncTime, startTime); + + // Get the list of products that need updating + const [products] = await localConnection.query(` + SELECT + t.pid, + t.title, + t.description, + t.sku, + t.stock_quantity, + t.preorder_count, + t.notions_inv_count, + t.price, + t.regular_price, + t.cost_price, + t.vendor, + t.vendor_reference, + t.notions_reference, + t.brand, + t.line, + t.subline, + t.artist, + t.categories, + t.created_at, + t.first_received, + t.landing_cost_price, + t.barcode, + t.harmonized_tariff_code, + t.updated_at, + t.visible, + t.managing_stock, + t.replenishable, + t.permalink, + t.moq, + t.rating, + t.reviews, + t.weight, + t.length, + t.width, + t.height, + t.country_of_origin, + t.location, + t.total_sold, + t.baskets, + t.notifies, + t.date_last_sold, + t.primary_iid, + t.image, + t.image_175, + t.image_full, + t.options, + t.tags + FROM temp_products t + WHERE t.needs_update = true + `); + + // Process products in batches + let recordsAdded = 0; + let recordsUpdated = 0; + + for (let i = 0; i < products.rows.length; i += BATCH_SIZE) { + const batch = products.rows.slice(i, i + BATCH_SIZE); + + const placeholders = batch.map((_, idx) => { + const base = idx * 47; // 47 columns + return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`; + }).join(','); + + const values = batch.flatMap(row => { + const imageUrls = getImageUrls(row.pid, row.primary_iid || 1); + return [ + row.pid, + row.title, + row.description, + row.sku || '', + row.stock_quantity > 5000 ? 0 : Math.max(0, row.stock_quantity), + row.preorder_count, + row.notions_inv_count, + row.price, + row.regular_price, + row.cost_price, + row.vendor, + row.vendor_reference, + row.notions_reference, + row.brand, + row.line, + row.subline, + row.artist, + row.categories, + validateDate(row.created_at), + validateDate(row.first_received), + row.landing_cost_price, + row.barcode, + row.harmonized_tariff_code, + validateDate(row.updated_at), + row.visible, + row.managing_stock, + row.replenishable, + row.permalink, + row.moq, + 1, + row.rating, + row.reviews, + row.weight, + row.length, + row.width, + row.height, + row.country_of_origin, + row.location, + row.total_sold, + row.baskets, + row.notifies, + validateDate(row.date_last_sold), + imageUrls.image, + imageUrls.image_175, + imageUrls.image_full, + row.options, + row.tags + ]; + }); + + const [result] = await localConnection.query(` + WITH upserted AS ( + INSERT INTO products ( + pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, + price, regular_price, cost_price, vendor, vendor_reference, notions_reference, + brand, line, subline, artist, categories, created_at, first_received, + landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, + managing_stock, replenishable, permalink, moq, uom, rating, reviews, + weight, length, width, height, country_of_origin, location, total_sold, + baskets, notifies, date_last_sold, image, image_175, image_full, options, tags + ) + VALUES ${placeholders} + ON CONFLICT (pid) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + sku = EXCLUDED.sku, + stock_quantity = EXCLUDED.stock_quantity, + preorder_count = EXCLUDED.preorder_count, + notions_inv_count = EXCLUDED.notions_inv_count, + price = EXCLUDED.price, + regular_price = EXCLUDED.regular_price, + cost_price = EXCLUDED.cost_price, + vendor = EXCLUDED.vendor, + vendor_reference = EXCLUDED.vendor_reference, + notions_reference = EXCLUDED.notions_reference, + brand = EXCLUDED.brand, + line = EXCLUDED.line, + subline = EXCLUDED.subline, + artist = EXCLUDED.artist, + created_at = EXCLUDED.created_at, + first_received = EXCLUDED.first_received, + landing_cost_price = EXCLUDED.landing_cost_price, + barcode = EXCLUDED.barcode, + harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, + updated_at = EXCLUDED.updated_at, + visible = EXCLUDED.visible, + managing_stock = EXCLUDED.managing_stock, + replenishable = EXCLUDED.replenishable, + permalink = EXCLUDED.permalink, + moq = EXCLUDED.moq, + uom = EXCLUDED.uom, + rating = EXCLUDED.rating, + reviews = EXCLUDED.reviews, + weight = EXCLUDED.weight, + length = EXCLUDED.length, + width = EXCLUDED.width, + height = EXCLUDED.height, + country_of_origin = EXCLUDED.country_of_origin, + location = EXCLUDED.location, + total_sold = EXCLUDED.total_sold, + baskets = EXCLUDED.baskets, + notifies = EXCLUDED.notifies, + date_last_sold = EXCLUDED.date_last_sold, + image = EXCLUDED.image, + image_175 = EXCLUDED.image_175, + image_full = EXCLUDED.image_full, + options = EXCLUDED.options, + tags = EXCLUDED.tags + RETURNING + xmax = 0 as inserted + ) + SELECT + COUNT(*) FILTER (WHERE inserted) as inserted, + COUNT(*) FILTER (WHERE NOT inserted) as updated + FROM upserted + `, values); + + recordsAdded += parseInt(result.rows[0].inserted, 10) || 0; + recordsUpdated += parseInt(result.rows[0].updated, 10) || 0; + + // Process category relationships in batches + const allCategories = []; + for (const row of batch) { + if (row.categories) { + const categoryIds = row.categories.split(',').filter(id => id && id.trim()); + if (categoryIds.length > 0) { + categoryIds.forEach(catId => { + allCategories.push([row.pid, parseInt(catId.trim(), 10)]); + }); + } + } + } + + // If we have categories to process + if (allCategories.length > 0) { + // First get all products in this batch + const productIds = batch.map(p => p.pid); + + // Delete all existing relationships for products in this batch + await localConnection.query( + 'DELETE FROM product_categories WHERE pid = ANY($1)', + [productIds] + ); + + // Insert all new relationships in one batch + const catPlaceholders = allCategories.map((_, idx) => + `($${idx * 2 + 1}, $${idx * 2 + 2})` + ).join(','); + + const catValues = allCategories.flat(); + + await localConnection.query(` + INSERT INTO product_categories (pid, cat_id) + VALUES ${catPlaceholders} + ON CONFLICT (pid, cat_id) DO NOTHING + `, catValues); + } + + outputProgress({ + status: "running", + operation: "Products import", + message: `Processing products: ${i + batch.length} of ${products.rows.length}`, + current: i + batch.length, + total: products.rows.length, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, i + batch.length, products.rows.length), + rate: calculateRate(startTime, i + batch.length) + }); + } + + // Cleanup temporary tables + await cleanupTemporaryTables(localConnection); + + // Commit the transaction + await localConnection.commit(); + + // Update sync status + await localConnection.query(` + INSERT INTO sync_status (table_name, last_sync_timestamp) + VALUES ('products', NOW()) + ON CONFLICT (table_name) DO UPDATE SET + last_sync_timestamp = NOW() + `); + + return { + status: 'complete', + recordsAdded, + recordsUpdated, + totalRecords: products.rows.length, + totalProcessed: materializeResult.totalProcessed, + duration: formatElapsedTime(startTime), + needsUpdate: materializeResult.needsUpdate, + skippedUnchanged: materializeResult.skipped + }; + } catch (error) { + // Rollback on error + await localConnection.rollback(); + throw error; + } + } catch (error) { + console.error('Error in importProducts:', error); + throw error; + } +} + +module.exports = { + importProducts, + importMissingProducts, + setupTemporaryTables, + cleanupTemporaryTables, + materializeCalculations +}; \ No newline at end of file diff --git a/inventory-server/scripts/import/purchase-orders.js b/inventory-server/scripts/import/purchase-orders.js new file mode 100644 index 0000000..39eee9e --- /dev/null +++ b/inventory-server/scripts/import/purchase-orders.js @@ -0,0 +1,884 @@ +const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('../metrics-new/utils/progress'); + +/** + * Validates a date from MySQL before inserting it into PostgreSQL + * @param {string|Date|null} mysqlDate - Date string or object from MySQL + * @returns {string|null} Valid date string or null if invalid + */ +function validateDate(mysqlDate) { + // Handle null, undefined, or empty values + if (!mysqlDate) { + return null; + } + + // Convert to string if it's not already + const dateStr = String(mysqlDate); + + // Handle MySQL zero dates and empty values + if (dateStr === '0000-00-00' || + dateStr === '0000-00-00 00:00:00' || + dateStr.indexOf('0000-00-00') !== -1 || + dateStr === '') { + return null; + } + + // Check if the date is valid + const date = new Date(mysqlDate); + + // If the date is invalid or suspiciously old (pre-1970), return null + if (isNaN(date.getTime()) || date.getFullYear() < 1970) { + return null; + } + + return mysqlDate; +} + +/** + * Imports purchase orders and receivings from a production MySQL database to a local PostgreSQL database. + * Handles these as separate data streams without complex FIFO allocation. + * + * @param {object} prodConnection - A MySQL connection to production DB + * @param {object} localConnection - A PostgreSQL connection to local DB + * @param {boolean} incrementalUpdate - Set to false for a full sync; true for incremental + * @returns {object} Information about the sync operation + */ +async function importPurchaseOrders(prodConnection, localConnection, incrementalUpdate = true) { + const startTime = Date.now(); + let poRecordsAdded = 0; + let poRecordsUpdated = 0; + let poRecordsDeleted = 0; + let receivingRecordsAdded = 0; + let receivingRecordsUpdated = 0; + let receivingRecordsDeleted = 0; + let totalProcessed = 0; + + // Batch size constants + const PO_BATCH_SIZE = 500; + const INSERT_BATCH_SIZE = 100; + + try { + // Begin transaction for the entire import process + await localConnection.beginTransaction(); + + // Get last sync info + const [syncInfo] = await localConnection.query( + "SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'purchase_orders'" + ); + const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01'; + + console.log('Purchase Orders: Using last sync time:', lastSyncTime); + + // Create temp tables for processing + await localConnection.query(` + DROP TABLE IF EXISTS temp_purchase_orders; + DROP TABLE IF EXISTS temp_receivings; + DROP TABLE IF EXISTS employee_names; + DROP TABLE IF EXISTS temp_supplier_names; + + -- Temporary table for purchase orders + CREATE TEMP TABLE temp_purchase_orders ( + po_id TEXT NOT NULL, + pid BIGINT NOT NULL, + sku TEXT, + name TEXT, + vendor TEXT, + date TIMESTAMP WITH TIME ZONE, + expected_date DATE, + status TEXT, + notes TEXT, + long_note TEXT, + ordered INTEGER, + po_cost_price NUMERIC(14, 4), + supplier_id INTEGER, + date_created TIMESTAMP WITH TIME ZONE, + date_ordered TIMESTAMP WITH TIME ZONE, + PRIMARY KEY (po_id, pid) + ); + + -- Temporary table for receivings + CREATE TEMP TABLE temp_receivings ( + receiving_id TEXT NOT NULL, + pid BIGINT NOT NULL, + sku TEXT, + name TEXT, + vendor TEXT, + qty_each INTEGER, + qty_each_orig INTEGER, + cost_each NUMERIC(14, 5), + cost_each_orig NUMERIC(14, 5), + received_by INTEGER, + received_by_name TEXT, + received_date TIMESTAMP WITH TIME ZONE, + receiving_created_date TIMESTAMP WITH TIME ZONE, + supplier_id INTEGER, + status TEXT, + PRIMARY KEY (receiving_id, pid) + ); + + -- Temporary table for employee names + CREATE TEMP TABLE employee_names ( + employeeid INTEGER PRIMARY KEY, + firstname TEXT, + lastname TEXT + ); + + -- Create indexes for efficient joins + CREATE INDEX idx_temp_po_pid ON temp_purchase_orders(pid); + CREATE INDEX idx_temp_receiving_pid ON temp_receivings(pid); + `); + + // Map status codes to text values + const poStatusMap = { + 0: 'canceled', + 1: 'created', + 10: 'electronically_ready_send', + 11: 'ordered', + 12: 'preordered', + 13: 'electronically_sent', + 15: 'receiving_started', + 50: 'done' + }; + + const receivingStatusMap = { + 0: 'canceled', + 1: 'created', + 30: 'partial_received', + 40: 'full_received', + 50: 'paid' + }; + + // Get time window for data retrieval + const yearInterval = incrementalUpdate ? 1 : 5; + + // Fetch employee data from production + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: "Fetching employee data" + }); + + const [employees] = await prodConnection.query(` + SELECT + employeeid, + firstname, + lastname + FROM employees + `); + + // Insert employee data into temp table + if (employees.length > 0) { + const employeeValues = employees.map(emp => [ + emp.employeeid, + emp.firstname || '', + emp.lastname || '' + ]).flat(); + + const placeholders = employees.map((_, idx) => { + const base = idx * 3; + return `($${base + 1}, $${base + 2}, $${base + 3})`; + }).join(','); + + await localConnection.query(` + INSERT INTO employee_names (employeeid, firstname, lastname) + VALUES ${placeholders} + ON CONFLICT (employeeid) DO UPDATE SET + firstname = EXCLUDED.firstname, + lastname = EXCLUDED.lastname + `, employeeValues); + } + + // Add this section before the PO import to create a supplier names mapping + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: "Fetching supplier data for vendor mapping" + }); + + // Fetch supplier data from production and store in a temp table + const [suppliers] = await prodConnection.query(` + SELECT + supplierid, + companyname + FROM suppliers + WHERE companyname IS NOT NULL AND companyname != '' + `); + + if (suppliers.length > 0) { + // Create temp table for supplier names + await localConnection.query(` + DROP TABLE IF EXISTS temp_supplier_names; + CREATE TEMP TABLE temp_supplier_names ( + supplier_id INTEGER PRIMARY KEY, + company_name TEXT NOT NULL + ); + `); + + // Insert supplier data in batches + for (let i = 0; i < suppliers.length; i += INSERT_BATCH_SIZE) { + const batch = suppliers.slice(i, i + INSERT_BATCH_SIZE); + + const placeholders = batch.map((_, idx) => { + const base = idx * 2; + return `($${base + 1}, $${base + 2})`; + }).join(','); + + const values = batch.flatMap(s => [ + s.supplierid, + s.companyname || 'Unnamed Supplier' + ]); + + await localConnection.query(` + INSERT INTO temp_supplier_names (supplier_id, company_name) + VALUES ${placeholders} + ON CONFLICT (supplier_id) DO UPDATE SET + company_name = EXCLUDED.company_name + `, values); + } + } + + // 1. Fetch and process purchase orders + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: "Fetching purchase orders" + }); + + const [poCount] = await prodConnection.query(` + SELECT COUNT(*) as total + FROM po p + WHERE p.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR) + ${incrementalUpdate ? ` + AND ( + p.date_updated > ? + OR p.date_ordered > ? + OR p.date_estin > ? + ) + ` : ''} + `, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); + + const totalPOs = poCount[0].total; + console.log(`Found ${totalPOs} relevant purchase orders`); + + // Skip processing if no POs to process + if (totalPOs === 0) { + console.log('No purchase orders to process, skipping PO import step'); + } else { + // Fetch and process POs in batches + let offset = 0; + let allPOsProcessed = false; + + while (!allPOsProcessed) { + const [poList] = await prodConnection.query(` + SELECT + p.po_id, + p.supplier_id, + s.companyname AS vendor, + p.status, + p.notes AS long_note, + p.short_note AS notes, + p.date_created, + p.date_ordered, + p.date_estin + FROM po p + LEFT JOIN suppliers s ON p.supplier_id = s.supplierid + WHERE p.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR) + ${incrementalUpdate ? ` + AND ( + p.date_updated > ? + OR p.date_ordered > ? + OR p.date_estin > ? + ) + ` : ''} + ORDER BY p.po_id + LIMIT ${PO_BATCH_SIZE} OFFSET ${offset} + `, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []); + + if (poList.length === 0) { + allPOsProcessed = true; + break; + } + + // Get products for these POs + const poIds = poList.map(po => po.po_id); + + const [poProducts] = await prodConnection.query(` + SELECT + pp.po_id, + pp.pid, + pp.qty_each, + pp.cost_each, + COALESCE(p.itemnumber, 'NO-SKU') AS sku, + COALESCE(p.description, 'Unknown Product') AS name + FROM po_products pp + LEFT JOIN products p ON pp.pid = p.pid + WHERE pp.po_id IN (?) + `, [poIds]); + + // Build complete PO records + const completePOs = []; + for (const product of poProducts) { + const po = poList.find(p => p.po_id == product.po_id); + if (!po) continue; + + completePOs.push({ + po_id: po.po_id.toString(), + pid: product.pid, + sku: product.sku, + name: product.name, + vendor: po.vendor || 'Unknown Vendor', + date: validateDate(po.date_ordered) || validateDate(po.date_created), + expected_date: validateDate(po.date_estin), + status: poStatusMap[po.status] || 'created', + notes: po.notes || '', + long_note: po.long_note || '', + ordered: product.qty_each, + po_cost_price: product.cost_each, + supplier_id: po.supplier_id, + date_created: validateDate(po.date_created), + date_ordered: validateDate(po.date_ordered) + }); + } + + // Insert PO data in batches + for (let i = 0; i < completePOs.length; i += INSERT_BATCH_SIZE) { + const batch = completePOs.slice(i, i + INSERT_BATCH_SIZE); + + const placeholders = batch.map((_, idx) => { + const base = idx * 15; + return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`; + }).join(','); + + const values = batch.flatMap(po => [ + po.po_id, + po.pid, + po.sku, + po.name, + po.vendor, + po.date, + po.expected_date, + po.status, + po.notes, + po.long_note, + po.ordered, + po.po_cost_price, + po.supplier_id, + po.date_created, + po.date_ordered + ]); + + await localConnection.query(` + INSERT INTO temp_purchase_orders ( + po_id, pid, sku, name, vendor, date, expected_date, status, notes, long_note, + ordered, po_cost_price, supplier_id, date_created, date_ordered + ) + VALUES ${placeholders} + ON CONFLICT (po_id, pid) DO UPDATE SET + sku = EXCLUDED.sku, + name = EXCLUDED.name, + vendor = EXCLUDED.vendor, + date = EXCLUDED.date, + expected_date = EXCLUDED.expected_date, + status = EXCLUDED.status, + notes = EXCLUDED.notes, + long_note = EXCLUDED.long_note, + ordered = EXCLUDED.ordered, + po_cost_price = EXCLUDED.po_cost_price, + supplier_id = EXCLUDED.supplier_id, + date_created = EXCLUDED.date_created, + date_ordered = EXCLUDED.date_ordered + `, values); + } + + offset += poList.length; + totalProcessed += completePOs.length; + + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: `Processed ${offset} of ${totalPOs} purchase orders (${totalProcessed} line items)`, + current: offset, + total: totalPOs, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, offset, totalPOs), + rate: calculateRate(startTime, offset) + }); + + if (poList.length < PO_BATCH_SIZE) { + allPOsProcessed = true; + } + } + } + + // 2. Next, fetch all relevant receivings + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: "Fetching receivings data" + }); + + const [receivingCount] = await prodConnection.query(` + SELECT COUNT(*) as total + FROM receivings r + WHERE r.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR) + ${incrementalUpdate ? ` + AND ( + r.date_updated > ? + OR r.date_created > ? + ) + ` : ''} + `, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []); + + const totalReceivings = receivingCount[0].total; + console.log(`Found ${totalReceivings} relevant receivings`); + + // Skip processing if no receivings to process + if (totalReceivings === 0) { + console.log('No receivings to process, skipping receivings import step'); + } else { + // Fetch and process receivings in batches + offset = 0; // Reset offset for receivings + let allReceivingsProcessed = false; + + while (!allReceivingsProcessed) { + const [receivingList] = await prodConnection.query(` + SELECT + r.receiving_id, + r.supplier_id, + r.status, + r.notes, + r.shipping, + r.total_amount, + r.hold, + r.for_storefront, + r.date_created, + r.date_paid, + r.date_checked + FROM receivings r + WHERE r.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR) + ${incrementalUpdate ? ` + AND ( + r.date_updated > ? + OR r.date_created > ? + ) + ` : ''} + ORDER BY r.receiving_id + LIMIT ${PO_BATCH_SIZE} OFFSET ${offset} + `, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []); + + if (receivingList.length === 0) { + allReceivingsProcessed = true; + break; + } + + // Get products for these receivings + const receivingIds = receivingList.map(r => r.receiving_id); + + const [receivingProducts] = await prodConnection.query(` + SELECT + rp.receiving_id, + rp.pid, + rp.qty_each, + rp.qty_each_orig, + rp.cost_each, + rp.cost_each_orig, + rp.received_by, + rp.received_date, + r.date_created as receiving_created_date, + COALESCE(p.itemnumber, 'NO-SKU') AS sku, + COALESCE(p.description, 'Unknown Product') AS name + FROM receivings_products rp + JOIN receivings r ON rp.receiving_id = r.receiving_id + LEFT JOIN products p ON rp.pid = p.pid + WHERE rp.receiving_id IN (?) + `, [receivingIds]); + + // Build complete receiving records + const completeReceivings = []; + for (const product of receivingProducts) { + const receiving = receivingList.find(r => r.receiving_id == product.receiving_id); + if (!receiving) continue; + + // Get employee name if available + let receivedByName = null; + if (product.received_by) { + const [employeeResult] = await localConnection.query(` + SELECT CONCAT(firstname, ' ', lastname) as full_name + FROM employee_names + WHERE employeeid = $1 + `, [product.received_by]); + + if (employeeResult.rows.length > 0) { + receivedByName = employeeResult.rows[0].full_name; + } + } + + // Get vendor name if available + let vendorName = 'Unknown Vendor'; + if (receiving.supplier_id) { + const [vendorResult] = await localConnection.query(` + SELECT company_name + FROM temp_supplier_names + WHERE supplier_id = $1 + `, [receiving.supplier_id]); + + if (vendorResult.rows.length > 0) { + vendorName = vendorResult.rows[0].company_name; + } + } + + completeReceivings.push({ + receiving_id: receiving.receiving_id.toString(), + pid: product.pid, + sku: product.sku, + name: product.name, + vendor: vendorName, + qty_each: product.qty_each, + qty_each_orig: product.qty_each_orig, + cost_each: product.cost_each, + cost_each_orig: product.cost_each_orig, + received_by: product.received_by, + received_by_name: receivedByName, + received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date), + receiving_created_date: validateDate(product.receiving_created_date), + supplier_id: receiving.supplier_id, + status: receivingStatusMap[receiving.status] || 'created' + }); + } + + // Insert receiving data in batches + for (let i = 0; i < completeReceivings.length; i += INSERT_BATCH_SIZE) { + const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE); + + const placeholders = batch.map((_, idx) => { + const base = idx * 15; + return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`; + }).join(','); + + const values = batch.flatMap(r => [ + r.receiving_id, + r.pid, + r.sku, + r.name, + r.vendor, + r.qty_each, + r.qty_each_orig, + r.cost_each, + r.cost_each_orig, + r.received_by, + r.received_by_name, + r.received_date, + r.receiving_created_date, + r.supplier_id, + r.status + ]); + + await localConnection.query(` + INSERT INTO temp_receivings ( + receiving_id, pid, sku, name, vendor, qty_each, qty_each_orig, + cost_each, cost_each_orig, received_by, received_by_name, + received_date, receiving_created_date, supplier_id, status + ) + VALUES ${placeholders} + ON CONFLICT (receiving_id, pid) DO UPDATE SET + sku = EXCLUDED.sku, + name = EXCLUDED.name, + vendor = EXCLUDED.vendor, + qty_each = EXCLUDED.qty_each, + qty_each_orig = EXCLUDED.qty_each_orig, + cost_each = EXCLUDED.cost_each, + cost_each_orig = EXCLUDED.cost_each_orig, + received_by = EXCLUDED.received_by, + received_by_name = EXCLUDED.received_by_name, + received_date = EXCLUDED.received_date, + receiving_created_date = EXCLUDED.receiving_created_date, + supplier_id = EXCLUDED.supplier_id, + status = EXCLUDED.status + `, values); + } + + offset += receivingList.length; + totalProcessed += completeReceivings.length; + + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: `Processed ${offset} of ${totalReceivings} receivings (${totalProcessed} line items total)`, + current: offset, + total: totalReceivings, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, offset, totalReceivings), + rate: calculateRate(startTime, offset) + }); + + if (receivingList.length < PO_BATCH_SIZE) { + allReceivingsProcessed = true; + } + } + } + + // Add this section to filter out invalid PIDs before final import + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: "Validating product IDs before final import" + }); + + await localConnection.query(` + -- Create temp table to store invalid PIDs + DROP TABLE IF EXISTS temp_invalid_pids; + CREATE TEMP TABLE temp_invalid_pids AS ( + -- Get all unique PIDs from our temp tables + WITH all_pids AS ( + SELECT DISTINCT pid FROM temp_purchase_orders + UNION + SELECT DISTINCT pid FROM temp_receivings + ) + -- Filter to only those that don't exist in products table + SELECT p.pid + FROM all_pids p + WHERE NOT EXISTS ( + SELECT 1 FROM products WHERE pid = p.pid + ) + ); + + -- Remove purchase orders with invalid PIDs + DELETE FROM temp_purchase_orders + WHERE pid IN (SELECT pid FROM temp_invalid_pids); + + -- Remove receivings with invalid PIDs + DELETE FROM temp_receivings + WHERE pid IN (SELECT pid FROM temp_invalid_pids); + `); + + // Get count of filtered items for reporting + const [filteredResult] = await localConnection.query(` + SELECT COUNT(*) as count FROM temp_invalid_pids + `); + const filteredCount = filteredResult.rows[0].count; + + if (filteredCount > 0) { + console.log(`Filtered out ${filteredCount} items with invalid product IDs`); + } + + // 3. Insert final purchase order records to the actual table + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: "Inserting final purchase order records" + }); + + // Create a temp table to track PO IDs being processed + await localConnection.query(` + DROP TABLE IF EXISTS processed_po_ids; + CREATE TEMP TABLE processed_po_ids AS ( + SELECT DISTINCT po_id FROM temp_purchase_orders + ); + `); + + // Delete products that were removed from POs and count them + const [poDeletedResult] = await localConnection.query(` + WITH deleted AS ( + DELETE FROM purchase_orders + WHERE po_id IN (SELECT po_id FROM processed_po_ids) + AND NOT EXISTS ( + SELECT 1 FROM temp_purchase_orders tp + WHERE purchase_orders.po_id = tp.po_id AND purchase_orders.pid = tp.pid + ) + RETURNING po_id, pid + ) + SELECT COUNT(*) as count FROM deleted + `); + + poRecordsDeleted = poDeletedResult.rows[0]?.count || 0; + console.log(`Deleted ${poRecordsDeleted} products that were removed from purchase orders`); + + const [poResult] = await localConnection.query(` + INSERT INTO purchase_orders ( + po_id, vendor, date, expected_date, pid, sku, name, + po_cost_price, status, notes, long_note, + ordered, supplier_id, date_created, date_ordered + ) + SELECT + po_id, + vendor, + COALESCE(date, date_created, now()) as date, + expected_date, + pid, + sku, + name, + po_cost_price, + status, + notes, + long_note, + ordered, + supplier_id, + date_created, + date_ordered + FROM temp_purchase_orders + ON CONFLICT (po_id, pid) DO UPDATE SET + vendor = EXCLUDED.vendor, + date = EXCLUDED.date, + expected_date = EXCLUDED.expected_date, + sku = EXCLUDED.sku, + name = EXCLUDED.name, + po_cost_price = EXCLUDED.po_cost_price, + status = EXCLUDED.status, + notes = EXCLUDED.notes, + long_note = EXCLUDED.long_note, + ordered = EXCLUDED.ordered, + supplier_id = EXCLUDED.supplier_id, + date_created = EXCLUDED.date_created, + date_ordered = EXCLUDED.date_ordered, + updated = CURRENT_TIMESTAMP + WHERE -- Only update if at least one key field has changed + purchase_orders.ordered IS DISTINCT FROM EXCLUDED.ordered OR + purchase_orders.po_cost_price IS DISTINCT FROM EXCLUDED.po_cost_price OR + purchase_orders.status IS DISTINCT FROM EXCLUDED.status OR + purchase_orders.expected_date IS DISTINCT FROM EXCLUDED.expected_date OR + purchase_orders.date IS DISTINCT FROM EXCLUDED.date OR + purchase_orders.vendor IS DISTINCT FROM EXCLUDED.vendor + RETURNING (xmax = 0) as inserted + `); + + poRecordsAdded = poResult.rows.filter(r => r.inserted).length; + poRecordsUpdated = poResult.rows.filter(r => !r.inserted).length; + + // 4. Insert final receiving records to the actual table + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: "Inserting final receiving records" + }); + + // Create a temp table to track receiving IDs being processed + await localConnection.query(` + DROP TABLE IF EXISTS processed_receiving_ids; + CREATE TEMP TABLE processed_receiving_ids AS ( + SELECT DISTINCT receiving_id FROM temp_receivings + ); + `); + + // Delete products that were removed from receivings and count them + const [receivingDeletedResult] = await localConnection.query(` + WITH deleted AS ( + DELETE FROM receivings + WHERE receiving_id IN (SELECT receiving_id FROM processed_receiving_ids) + AND NOT EXISTS ( + SELECT 1 FROM temp_receivings tr + WHERE receivings.receiving_id = tr.receiving_id AND receivings.pid = tr.pid + ) + RETURNING receiving_id, pid + ) + SELECT COUNT(*) as count FROM deleted + `); + + receivingRecordsDeleted = receivingDeletedResult.rows[0]?.count || 0; + console.log(`Deleted ${receivingRecordsDeleted} products that were removed from receivings`); + + const [receivingsResult] = await localConnection.query(` + INSERT INTO receivings ( + receiving_id, pid, sku, name, vendor, qty_each, qty_each_orig, + cost_each, cost_each_orig, received_by, received_by_name, + received_date, receiving_created_date, supplier_id, status + ) + SELECT + receiving_id, + pid, + sku, + name, + vendor, + qty_each, + qty_each_orig, + cost_each, + cost_each_orig, + received_by, + received_by_name, + COALESCE(received_date, receiving_created_date, now()) as received_date, + receiving_created_date, + supplier_id, + status + FROM temp_receivings + ON CONFLICT (receiving_id, pid) DO UPDATE SET + sku = EXCLUDED.sku, + name = EXCLUDED.name, + vendor = EXCLUDED.vendor, + qty_each = EXCLUDED.qty_each, + qty_each_orig = EXCLUDED.qty_each_orig, + cost_each = EXCLUDED.cost_each, + cost_each_orig = EXCLUDED.cost_each_orig, + received_by = EXCLUDED.received_by, + received_by_name = EXCLUDED.received_by_name, + received_date = EXCLUDED.received_date, + receiving_created_date = EXCLUDED.receiving_created_date, + supplier_id = EXCLUDED.supplier_id, + status = EXCLUDED.status, + updated = CURRENT_TIMESTAMP + WHERE -- Only update if at least one key field has changed + receivings.qty_each IS DISTINCT FROM EXCLUDED.qty_each OR + receivings.cost_each IS DISTINCT FROM EXCLUDED.cost_each OR + receivings.status IS DISTINCT FROM EXCLUDED.status OR + receivings.received_date IS DISTINCT FROM EXCLUDED.received_date OR + receivings.received_by IS DISTINCT FROM EXCLUDED.received_by + RETURNING (xmax = 0) as inserted + `); + + receivingRecordsAdded = receivingsResult.rows.filter(r => r.inserted).length; + receivingRecordsUpdated = receivingsResult.rows.filter(r => !r.inserted).length; + + // Update sync status + await localConnection.query(` + INSERT INTO sync_status (table_name, last_sync_timestamp) + VALUES ('purchase_orders', NOW()) + ON CONFLICT (table_name) DO UPDATE SET + last_sync_timestamp = NOW() + `); + + // Clean up temporary tables + await localConnection.query(` + DROP TABLE IF EXISTS temp_purchase_orders; + DROP TABLE IF EXISTS temp_receivings; + DROP TABLE IF EXISTS employee_names; + DROP TABLE IF EXISTS temp_supplier_names; + DROP TABLE IF EXISTS temp_invalid_pids; + DROP TABLE IF EXISTS processed_po_ids; + DROP TABLE IF EXISTS processed_receiving_ids; + `); + + // Commit transaction + await localConnection.commit(); + + return { + status: "complete", + recordsAdded: poRecordsAdded + receivingRecordsAdded, + recordsUpdated: poRecordsUpdated + receivingRecordsUpdated, + recordsDeleted: poRecordsDeleted + receivingRecordsDeleted, + poRecordsAdded, + poRecordsUpdated, + poRecordsDeleted, + receivingRecordsAdded, + receivingRecordsUpdated, + receivingRecordsDeleted, + totalRecords: totalProcessed + }; + } catch (error) { + console.error("Error during purchase orders import:", error); + + // Rollback transaction + try { + await localConnection.rollback(); + } catch (rollbackError) { + console.error('Error during rollback:', rollbackError.message); + } + + return { + status: "error", + error: error.message, + recordsAdded: 0, + recordsUpdated: 0, + recordsDeleted: 0, + totalRecords: 0 + }; + } +} + +module.exports = importPurchaseOrders; \ No newline at end of file diff --git a/inventory-server/scripts/import/utils.js b/inventory-server/scripts/import/utils.js new file mode 100644 index 0000000..b7d888d --- /dev/null +++ b/inventory-server/scripts/import/utils.js @@ -0,0 +1,156 @@ +const mysql = require("mysql2/promise"); +const { Client } = require("ssh2"); +const { Pool } = require('pg'); +const dotenv = require("dotenv"); +const path = require("path"); + +// Helper function to setup SSH tunnel +async function setupSshTunnel(sshConfig) { + return new Promise((resolve, reject) => { + const ssh = new Client(); + + ssh.on('error', (err) => { + console.error('SSH connection error:', err); + }); + + ssh.on('end', () => { + console.log('SSH connection ended normally'); + }); + + ssh.on('close', () => { + console.log('SSH connection closed'); + }); + + ssh + .on("ready", () => { + ssh.forwardOut( + "127.0.0.1", + 0, + sshConfig.prodDbConfig.host, + sshConfig.prodDbConfig.port, + async (err, stream) => { + if (err) reject(err); + resolve({ ssh, stream }); + } + ); + }) + .connect(sshConfig.ssh); + }); +} + +// Helper function to setup database connections +async function setupConnections(sshConfig) { + const tunnel = await setupSshTunnel(sshConfig); + + // Setup MySQL connection for production + const prodConnection = await mysql.createConnection({ + ...sshConfig.prodDbConfig, + stream: tunnel.stream, + }); + + // Setup PostgreSQL connection pool for local + const localPool = new Pool(sshConfig.localDbConfig); + + // Test the PostgreSQL connection + try { + const client = await localPool.connect(); + await client.query('SELECT NOW()'); + client.release(); + console.log('PostgreSQL connection successful'); + } catch (err) { + console.error('PostgreSQL connection error:', err); + throw err; + } + + // Create a wrapper for the PostgreSQL pool to match MySQL interface + const localConnection = { + _client: null, + _transactionActive: false, + + query: async (text, params) => { + // If we're not in a transaction, use the pool directly + if (!localConnection._transactionActive) { + const client = await localPool.connect(); + try { + const result = await client.query(text, params); + return [result]; + } finally { + client.release(); + } + } + + // If we're in a transaction, use the dedicated client + if (!localConnection._client) { + throw new Error('No active transaction client'); + } + const result = await localConnection._client.query(text, params); + return [result]; + }, + + beginTransaction: async () => { + if (localConnection._transactionActive) { + throw new Error('Transaction already active'); + } + localConnection._client = await localPool.connect(); + await localConnection._client.query('BEGIN'); + localConnection._transactionActive = true; + }, + + commit: async () => { + if (!localConnection._transactionActive) { + throw new Error('No active transaction to commit'); + } + await localConnection._client.query('COMMIT'); + localConnection._client.release(); + localConnection._client = null; + localConnection._transactionActive = false; + }, + + rollback: async () => { + if (!localConnection._transactionActive) { + throw new Error('No active transaction to rollback'); + } + await localConnection._client.query('ROLLBACK'); + localConnection._client.release(); + localConnection._client = null; + localConnection._transactionActive = false; + }, + + end: async () => { + if (localConnection._client) { + localConnection._client.release(); + localConnection._client = null; + } + await localPool.end(); + } + }; + + return { prodConnection, localConnection, tunnel }; +} + +// Helper function to close connections +async function closeConnections(connections) { + const { ssh, prodConnection, localConnection } = connections; + + try { + if (prodConnection) await prodConnection.end(); + if (localConnection) await localConnection.end(); + + // Wait a bit for any pending data to be written before closing SSH + await new Promise(resolve => setTimeout(resolve, 100)); + + if (ssh) { + ssh.on('close', () => { + console.log('SSH connection closed cleanly'); + }); + ssh.end(); + } + } catch (err) { + console.error('Error during cleanup:', err); + } +} + +module.exports = { + setupConnections, + closeConnections +}; \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/backfill/populate_initial_product_metrics.sql b/inventory-server/scripts/metrics-new/backfill/populate_initial_product_metrics.sql new file mode 100644 index 0000000..b78d27d --- /dev/null +++ b/inventory-server/scripts/metrics-new/backfill/populate_initial_product_metrics.sql @@ -0,0 +1,444 @@ +-- Description: Performs the first population OR full recalculation of the product_metrics table based on +-- historically backfilled daily_product_snapshots and current product/PO data. +-- Calculates all metrics considering the full available history up to 'yesterday'. +-- Run ONCE after backfill_historical_snapshots_final.sql completes successfully. +-- Dependencies: Core import tables (products, purchase_orders, receivings), daily_product_snapshots (historically populated), +-- configuration tables (settings_*), product_metrics table must exist. +-- Frequency: Run ONCE. +DO $$ +DECLARE + _module_name VARCHAR := 'product_metrics_population'; -- Generic name + _start_time TIMESTAMPTZ := clock_timestamp(); + -- Calculate metrics AS OF the end of the last fully completed day + _calculation_date DATE := CURRENT_DATE - INTERVAL '1 day'; +BEGIN + RAISE NOTICE 'Running % module. Calculating AS OF: %. Start Time: %', _module_name, _calculation_date, _start_time; + + -- Optional: Consider TRUNCATE if you want a completely fresh start, + -- otherwise ON CONFLICT will update existing rows if this is rerun. + -- TRUNCATE TABLE public.product_metrics; + RAISE NOTICE 'Populating product_metrics table. This may take some time...'; + + -- CTEs to gather necessary information AS OF _calculation_date + WITH CurrentInfo AS ( + -- Fetches current product details, including costs/prices used for forecasting & fallbacks + SELECT + p.pid, p.sku, p.title, p.brand, p.vendor, COALESCE(p.image_175, p.image) as image_url, + p.visible as is_visible, p.replenishable, + COALESCE(p.price, 0.00) as current_price, COALESCE(p.regular_price, 0.00) as current_regular_price, + COALESCE(p.cost_price, 0.00) as current_cost_price, + COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost + p.stock_quantity as current_stock, -- Use actual current stock for forecast base + p.created_at, p.first_received, p.date_last_sold, + p.moq, + p.uom, + p.total_sold as historical_total_sold -- Add historical total_sold from products table + FROM public.products p + ), + OnOrderInfo AS ( + -- Calculates current on-order quantities and costs + SELECT + pid, + SUM(ordered) AS on_order_qty, + SUM(ordered * po_cost_price) AS on_order_cost, + MIN(expected_date) AS earliest_expected_date + FROM public.purchase_orders + -- Use the most common statuses representing active, unfulfilled POs + WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started') + AND status NOT IN ('canceled', 'done') + GROUP BY pid + ), + HistoricalDates AS ( + -- Determines key historical dates from orders and receivings + SELECT + p.pid, + MIN(o.date)::date AS date_first_sold, + MAX(o.date)::date AS max_order_date, -- Used as fallback for date_last_sold + MIN(r.received_date)::date AS date_first_received_calc, + MAX(r.received_date)::date AS date_last_received_calc + FROM public.products p + LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned') + LEFT JOIN public.receivings r ON p.pid = r.pid + GROUP BY p.pid + ), + SnapshotAggregates AS ( + -- Aggregates metrics from historical snapshots up to the _calculation_date + SELECT + pid, + -- Rolling periods relative to _calculation_date + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '6 days' AND _calculation_date THEN units_sold ELSE 0 END) AS sales_7d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '6 days' AND _calculation_date THEN net_revenue ELSE 0 END) AS revenue_7d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '13 days' AND _calculation_date THEN units_sold ELSE 0 END) AS sales_14d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '13 days' AND _calculation_date THEN net_revenue ELSE 0 END) AS revenue_14d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN units_sold ELSE 0 END) AS sales_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN net_revenue ELSE 0 END) AS revenue_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN cogs ELSE 0 END) AS cogs_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN profit ELSE 0 END) AS profit_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN units_returned ELSE 0 END) AS returns_units_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN returns_revenue ELSE 0 END) AS returns_revenue_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN discounts ELSE 0 END) AS discounts_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN gross_revenue ELSE 0 END) AS gross_revenue_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN gross_regular_revenue ELSE 0 END) AS gross_regular_revenue_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date AND stockout_flag THEN 1 ELSE 0 END) AS stockout_days_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '364 days' AND _calculation_date THEN units_sold ELSE 0 END) AS sales_365d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '364 days' AND _calculation_date THEN net_revenue ELSE 0 END) AS revenue_365d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN units_received ELSE 0 END) AS received_qty_30d, + SUM(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN cost_received ELSE 0 END) AS received_cost_30d, + + -- Averages over the last 30 days ending _calculation_date + AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_quantity END) AS avg_stock_units_30d, + AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_cost END) AS avg_stock_cost_30d, + AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_retail END) AS avg_stock_retail_30d, + AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_gross END) AS avg_stock_gross_30d, + + -- Lifetime (Using historical total from products table) + (SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) AS lifetime_sales, + COALESCE( + -- Option 1: Use 30-day average price if available + CASE WHEN SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END) > 0 THEN + (SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) * ( + SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN net_revenue ELSE 0 END) / + NULLIF(SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END), 0) + ) + ELSE NULL END, + -- Option 2: Try 365-day average price if available + CASE WHEN SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END) > 0 THEN + (SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) * ( + SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN net_revenue ELSE 0 END) / + NULLIF(SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END), 0) + ) + ELSE NULL END, + -- Option 3: Use current price from products table + (SELECT total_sold * price FROM public.products WHERE public.products.pid = daily_product_snapshots.pid), + -- Option 4: Use regular price if current price might be zero + (SELECT total_sold * regular_price FROM public.products WHERE public.products.pid = daily_product_snapshots.pid), + -- Final fallback: Use accumulated revenue (less accurate for old products) + SUM(net_revenue) + ) AS lifetime_revenue, + + -- Yesterday (Sales for the specific _calculation_date) + SUM(CASE WHEN snapshot_date = _calculation_date THEN units_sold ELSE 0 END) as yesterday_sales + + FROM public.daily_product_snapshots + WHERE snapshot_date <= _calculation_date -- Ensure we only use data up to the calculation point + GROUP BY pid + ), + FirstPeriodMetrics AS ( + -- Calculates sales/revenue for first X days after first sale date + -- Uses HistoricalDates CTE to get the first sale date + SELECT + pid, date_first_sold, + SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '6 days' THEN units_sold ELSE 0 END) AS first_7_days_sales, + SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS first_7_days_revenue, + SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '29 days' THEN units_sold ELSE 0 END) AS first_30_days_sales, + SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '29 days' THEN net_revenue ELSE 0 END) AS first_30_days_revenue, + SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '59 days' THEN units_sold ELSE 0 END) AS first_60_days_sales, + SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '59 days' THEN net_revenue ELSE 0 END) AS first_60_days_revenue, + SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '89 days' THEN units_sold ELSE 0 END) AS first_90_days_sales, + SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '89 days' THEN net_revenue ELSE 0 END) AS first_90_days_revenue + FROM public.daily_product_snapshots ds + JOIN HistoricalDates hd USING(pid) + WHERE date_first_sold IS NOT NULL + AND snapshot_date >= date_first_sold -- Only consider snapshots after first sale + AND snapshot_date <= _calculation_date -- Only up to the overall calculation date + GROUP BY pid, date_first_sold + ), + Settings AS ( + -- Fetches effective configuration settings (Product > Vendor > Global) + SELECT + p.pid, + COALESCE(sp.lead_time_days, sv.default_lead_time_days, (SELECT setting_value FROM settings_global WHERE setting_key = 'default_lead_time_days')::int, 14) AS effective_lead_time, + COALESCE(sp.days_of_stock, sv.default_days_of_stock, (SELECT setting_value FROM settings_global WHERE setting_key = 'default_days_of_stock')::int, 30) AS effective_days_of_stock, + COALESCE(sp.safety_stock, (SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0) AS effective_safety_stock, + COALESCE(sp.exclude_from_forecast, FALSE) AS exclude_forecast + FROM public.products p + LEFT JOIN public.settings_product sp ON p.pid = sp.pid + LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor + ), + AvgLeadTime AS ( + -- Calculate Average Lead Time by joining purchase_orders with receivings + SELECT + po.pid, + AVG(GREATEST(1, + CASE + WHEN r.received_date IS NOT NULL AND po.date IS NOT NULL + THEN (r.received_date::date - po.date::date) + ELSE 1 + END + ))::int AS avg_lead_time_days_calc + FROM public.purchase_orders po + JOIN public.receivings r ON r.pid = po.pid + WHERE po.status = 'done' -- Completed POs + AND r.received_date IS NOT NULL + AND po.date IS NOT NULL + AND r.received_date >= po.date + GROUP BY po.pid + ), + RankedForABC AS ( + -- Ranks products based on the configured ABC metric (using historical data) + SELECT + p.pid, + CASE COALESCE((SELECT setting_value FROM settings_global WHERE setting_key = 'abc_calculation_basis'), 'revenue_30d') + WHEN 'sales_30d' THEN COALESCE(sa.sales_30d, 0) + WHEN 'lifetime_revenue' THEN COALESCE(sa.lifetime_revenue, 0)::numeric + ELSE COALESCE(sa.revenue_30d, 0) -- Default to revenue_30d + END AS metric_value + FROM public.products p -- Use products as the base + JOIN SnapshotAggregates sa ON p.pid = sa.pid + WHERE p.replenishable = TRUE -- Only rank replenishable products + AND (CASE COALESCE((SELECT setting_value FROM settings_global WHERE setting_key = 'abc_calculation_basis'), 'revenue_30d') + WHEN 'sales_30d' THEN COALESCE(sa.sales_30d, 0) + WHEN 'lifetime_revenue' THEN COALESCE(sa.lifetime_revenue, 0)::numeric + ELSE COALESCE(sa.revenue_30d, 0) + END) > 0 -- Only include products with non-zero contribution + ), + CumulativeABC AS ( + -- Calculates cumulative metric values for ABC ranking + SELECT + pid, metric_value, + SUM(metric_value) OVER (ORDER BY metric_value DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) as cumulative_metric, + SUM(metric_value) OVER () as total_metric + FROM RankedForABC + ), + FinalABC AS ( + -- Assigns A, B, or C class based on thresholds + SELECT + pid, + CASE + WHEN cumulative_metric / NULLIF(total_metric, 0) <= COALESCE((SELECT setting_value::numeric FROM settings_global WHERE setting_key = 'abc_revenue_threshold_a'), 0.8) THEN 'A'::char(1) + WHEN cumulative_metric / NULLIF(total_metric, 0) <= COALESCE((SELECT setting_value::numeric FROM settings_global WHERE setting_key = 'abc_revenue_threshold_b'), 0.95) THEN 'B'::char(1) + ELSE 'C'::char(1) + END AS abc_class_calc + FROM CumulativeABC + ) + -- Final INSERT/UPDATE statement using all the prepared CTEs + INSERT INTO public.product_metrics ( + pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable, + current_price, current_regular_price, current_cost_price, current_landing_cost_price, + current_stock, current_stock_cost, current_stock_retail, current_stock_gross, + on_order_qty, on_order_cost, on_order_retail, earliest_expected_date, + date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days, + sales_7d, revenue_7d, sales_14d, revenue_14d, sales_30d, revenue_30d, cogs_30d, profit_30d, + returns_units_30d, returns_revenue_30d, discounts_30d, gross_revenue_30d, gross_regular_revenue_30d, + stockout_days_30d, sales_365d, revenue_365d, + avg_stock_units_30d, avg_stock_cost_30d, avg_stock_retail_30d, avg_stock_gross_30d, + received_qty_30d, received_cost_30d, + lifetime_sales, lifetime_revenue, + first_7_days_sales, first_7_days_revenue, first_30_days_sales, first_30_days_revenue, + first_60_days_sales, first_60_days_revenue, first_90_days_sales, first_90_days_revenue, + asp_30d, acp_30d, avg_ros_30d, avg_sales_per_day_30d, + margin_30d, markup_30d, gmroi_30d, stockturn_30d, return_rate_30d, discount_rate_30d, + stockout_rate_30d, markdown_30d, markdown_rate_30d, sell_through_30d, + avg_lead_time_days, abc_class, + sales_velocity_daily, config_lead_time, config_days_of_stock, config_safety_stock, + planning_period_days, lead_time_forecast_units, days_of_stock_forecast_units, + planning_period_forecast_units, lead_time_closing_stock, days_of_stock_closing_stock, + replenishment_needed_raw, replenishment_units, replenishment_cost, replenishment_retail, replenishment_profit, + to_order_units, forecast_lost_sales_units, forecast_lost_revenue, + stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date, + overstocked_units, overstocked_cost, overstocked_retail, is_old_stock, + yesterday_sales + ) + SELECT + -- Select columns in order, joining all CTEs by pid + ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.replenishable, + ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost, + ci.current_stock, (ci.current_stock * COALESCE(ci.current_effective_cost, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_price, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_regular_price, 0.00))::numeric(12,2), + COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00)::numeric(12,2), (COALESCE(ooi.on_order_qty, 0) * COALESCE(ci.current_price, 0.00))::numeric(12,2), ooi.earliest_expected_date, + + -- Fix type issue with date calculation - properly cast timestamps to dates before arithmetic + ci.created_at::date, + COALESCE(ci.first_received::date, hd.date_first_received_calc), + hd.date_last_received_calc, + hd.date_first_sold, + COALESCE(ci.date_last_sold, hd.max_order_date), + -- Fix timestamp + integer error by ensuring we work only with dates + CASE + WHEN LEAST(ci.created_at::date, COALESCE(hd.date_first_sold, ci.created_at::date)) IS NOT NULL + THEN (_calculation_date::date - LEAST(ci.created_at::date, COALESCE(hd.date_first_sold, ci.created_at::date)))::int + ELSE NULL + END, + + COALESCE(sa.sales_7d, 0), COALESCE(sa.revenue_7d, 0), COALESCE(sa.sales_14d, 0), COALESCE(sa.revenue_14d, 0), COALESCE(sa.sales_30d, 0), COALESCE(sa.revenue_30d, 0), COALESCE(sa.cogs_30d, 0), COALESCE(sa.profit_30d, 0), + COALESCE(sa.returns_units_30d, 0), COALESCE(sa.returns_revenue_30d, 0), COALESCE(sa.discounts_30d, 0), COALESCE(sa.gross_revenue_30d, 0), COALESCE(sa.gross_regular_revenue_30d, 0), + COALESCE(sa.stockout_days_30d, 0), COALESCE(sa.sales_365d, 0), COALESCE(sa.revenue_365d, 0), + sa.avg_stock_units_30d, sa.avg_stock_cost_30d, sa.avg_stock_retail_30d, sa.avg_stock_gross_30d, -- Averages can be NULL if no data + COALESCE(sa.received_qty_30d, 0), COALESCE(sa.received_cost_30d, 0), + COALESCE(sa.lifetime_sales, 0), COALESCE(sa.lifetime_revenue, 0), + fpm.first_7_days_sales, fpm.first_7_days_revenue, fpm.first_30_days_sales, fpm.first_30_days_revenue, + fpm.first_60_days_sales, fpm.first_60_days_revenue, fpm.first_90_days_sales, fpm.first_90_days_revenue, + + -- Calculated KPIs (using COALESCE on inputs where appropriate) + sa.revenue_30d / NULLIF(sa.sales_30d, 0) AS asp_30d, + sa.cogs_30d / NULLIF(sa.sales_30d, 0) AS acp_30d, + sa.profit_30d / NULLIF(sa.sales_30d, 0) AS avg_ros_30d, + COALESCE(sa.sales_30d, 0) / 30.0 AS avg_sales_per_day_30d, + + -- Fix for percentages - cast to numeric with appropriate precision + ((sa.profit_30d / NULLIF(sa.revenue_30d, 0)) * 100)::numeric(8,2) AS margin_30d, + ((sa.profit_30d / NULLIF(sa.cogs_30d, 0)) * 100)::numeric(8,2) AS markup_30d, + sa.profit_30d / NULLIF(sa.avg_stock_cost_30d, 0) AS gmroi_30d, + sa.sales_30d / NULLIF(sa.avg_stock_units_30d, 0) AS stockturn_30d, + ((sa.returns_units_30d / NULLIF(COALESCE(sa.sales_30d, 0) + COALESCE(sa.returns_units_30d, 0), 0)) * 100)::numeric(8,2) AS return_rate_30d, + ((sa.discounts_30d / NULLIF(sa.gross_revenue_30d, 0)) * 100)::numeric(8,2) AS discount_rate_30d, + ((COALESCE(sa.stockout_days_30d, 0) / 30.0) * 100)::numeric(8,2) AS stockout_rate_30d, + GREATEST(0, sa.gross_regular_revenue_30d - sa.gross_revenue_30d) AS markdown_30d, -- Ensure markdown isn't negative + ((GREATEST(0, sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100)::numeric(8,2) AS markdown_rate_30d, + -- Sell Through Rate: Sales / (Stock at end of period + Sales). This is one definition proxying for Sales / Beginning Stock. + ((sa.sales_30d / NULLIF( + (SELECT eod_stock_quantity FROM daily_product_snapshots WHERE snapshot_date = _calculation_date AND pid = ci.pid LIMIT 1) + COALESCE(sa.sales_30d, 0) + , 0)) * 100)::numeric(8,2) AS sell_through_30d, + + -- Use calculated periodic metrics + alt.avg_lead_time_days_calc, + CASE + WHEN ci.replenishable = FALSE THEN NULL -- Non-replenishable don't get a class + ELSE COALESCE(fa.abc_class_calc, 'C') -- Default ranked replenishable but non-contributing to C + END, + + -- Forecasting intermediate values (based on historical aggregates ending _calculation_date) + (COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) AS sales_velocity_daily, -- Ensure divisor > 0 + s.effective_lead_time AS config_lead_time, s.effective_days_of_stock AS config_days_of_stock, s.effective_safety_stock AS config_safety_stock, + (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_days, + -- Calculate raw forecast need components (using safe velocity) + (COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time AS lead_time_forecast_units, + (COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock AS days_of_stock_forecast_units, + -- Planning period forecast units (sum of lead time and DOS units) + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock) AS planning_period_forecast_units, + -- Closing stock calculations (using raw forecast components for accuracy before rounding) + (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time)) AS lead_time_closing_stock, + ((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time))) + - ((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock) AS days_of_stock_closing_stock, + -- Raw replenishment needed + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) -- Use rounded forecast units + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw, + + -- Final Forecasting Metrics + -- Replenishment Units (calculated need, before MOQ) + CEILING(GREATEST(0, + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) + ))::int AS replenishment_units, + -- Replenishment Cost/Retail/Profit (based on replenishment_units) + (CEILING(GREATEST(0, + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) + ))::int) * COALESCE(ci.current_effective_cost, 0.00)::numeric(12,2) AS replenishment_cost, + (CEILING(GREATEST(0, + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) + ))::int) * COALESCE(ci.current_price, 0.00)::numeric(12,2) AS replenishment_retail, + (CEILING(GREATEST(0, + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) + ))::int) * (COALESCE(ci.current_price, 0.00) - COALESCE(ci.current_effective_cost, 0.00))::numeric(12,2) AS replenishment_profit, + + -- *** FIX: To Order Units (Apply MOQ rounding) *** + CASE + WHEN COALESCE(ci.moq, 0) <= 1 THEN -- Treat no/invalid MOQ or MOQ=1 as no rounding needed + CEILING(GREATEST(0, + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) + ))::int + ELSE -- Apply MOQ rounding: Round UP to nearest multiple of MOQ + (CEILING(GREATEST(0, + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) + ) / NULLIF(ci.moq::numeric, 0)) * COALESCE(ci.moq, 1))::int + END AS to_order_units, + + -- Forecast Lost Sales (Units occurring during lead time if current+on_order is insufficient) + CEILING(GREATEST(0, + ((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) -- Demand during lead time + - (ci.current_stock + COALESCE(ooi.on_order_qty, 0)) -- Supply available before order arrives + ))::int AS forecast_lost_sales_units, + -- Forecast Lost Revenue + (CEILING(GREATEST(0, + ((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + - (ci.current_stock + COALESCE(ooi.on_order_qty, 0)) + ))::int) * COALESCE(ci.current_price, 0.00)::numeric(12,2) AS forecast_lost_revenue, + + -- Stock Cover etc (using safe velocity) + ci.current_stock / NULLIF((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)), 0) AS stock_cover_in_days, + COALESCE(ooi.on_order_qty, 0) / NULLIF((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)), 0) AS po_cover_in_days, + (ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)), 0) AS sells_out_in_days, + -- Replenish Date (Project forward from 'today', which is _calculation_date + 1 day) + CASE + WHEN (COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) > 0 -- Check for positive velocity + THEN + _calculation_date + INTERVAL '1 day' -- Today + + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) -- Stock above safety + / (COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) -- divided by velocity + )::integer * INTERVAL '1 day' -- Gives date safety stock is hit + - s.effective_lead_time * INTERVAL '1 day' -- Subtract lead time + ELSE NULL -- Cannot calculate if no sales velocity + END AS replenish_date, + -- Overstocked Units (Stock above safety + planning period demand) + GREATEST(0, ci.current_stock - s.effective_safety_stock - + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) -- Demand during lead time + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) -- Demand during DOS + )::int AS overstocked_units, + (GREATEST(0, ci.current_stock - s.effective_safety_stock - + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + )::int) * COALESCE(ci.current_effective_cost, 0.00)::numeric(12,2) AS overstocked_cost, + (GREATEST(0, ci.current_stock - s.effective_safety_stock - + (CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_lead_time) + + CEILING((COALESCE(sa.sales_30d, 0) / NULLIF(GREATEST(1.0, 30.0 - COALESCE(sa.stockout_days_30d, 0)), 0)) * s.effective_days_of_stock)) + )::int) * COALESCE(ci.current_price, 0.00)::numeric(12,2) AS overstocked_retail, + -- Old Stock Flag + (ci.created_at::date < (_calculation_date - INTERVAL '60 day')::date) AND + (COALESCE(ci.date_last_sold, hd.max_order_date) IS NULL OR COALESCE(ci.date_last_sold, hd.max_order_date) < (_calculation_date - INTERVAL '60 day')::date) AND + (hd.date_last_received_calc IS NULL OR hd.date_last_received_calc < (_calculation_date - INTERVAL '60 day')::date) AND + COALESCE(ooi.on_order_qty, 0) = 0 AS is_old_stock, + COALESCE(sa.yesterday_sales, 0) -- Sales for _calculation_date + + FROM CurrentInfo ci + LEFT JOIN OnOrderInfo ooi ON ci.pid = ooi.pid + LEFT JOIN HistoricalDates hd ON ci.pid = hd.pid + LEFT JOIN SnapshotAggregates sa ON ci.pid = sa.pid + LEFT JOIN FirstPeriodMetrics fpm ON ci.pid = fpm.pid + LEFT JOIN Settings s ON ci.pid = s.pid + LEFT JOIN AvgLeadTime alt ON ci.pid = alt.pid -- Join calculated avg lead time + LEFT JOIN FinalABC fa ON ci.pid = fa.pid -- Join calculated ABC class + WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL + + ON CONFLICT (pid) DO UPDATE SET + -- *** IMPORTANT: List ALL columns here, ensuring order matches INSERT list *** + -- Update ALL columns to ensure entire row is refreshed + last_calculated = EXCLUDED.last_calculated, sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable, + current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price, + current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross, + on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date, + date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days, + sales_7d = EXCLUDED.sales_7d, revenue_7d = EXCLUDED.revenue_7d, sales_14d = EXCLUDED.sales_14d, revenue_14d = EXCLUDED.revenue_14d, sales_30d = EXCLUDED.sales_30d, revenue_30d = EXCLUDED.revenue_30d, cogs_30d = EXCLUDED.cogs_30d, profit_30d = EXCLUDED.profit_30d, + returns_units_30d = EXCLUDED.returns_units_30d, returns_revenue_30d = EXCLUDED.returns_revenue_30d, discounts_30d = EXCLUDED.discounts_30d, gross_revenue_30d = EXCLUDED.gross_revenue_30d, gross_regular_revenue_30d = EXCLUDED.gross_regular_revenue_30d, + stockout_days_30d = EXCLUDED.stockout_days_30d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, + avg_stock_units_30d = EXCLUDED.avg_stock_units_30d, avg_stock_cost_30d = EXCLUDED.avg_stock_cost_30d, avg_stock_retail_30d = EXCLUDED.avg_stock_retail_30d, avg_stock_gross_30d = EXCLUDED.avg_stock_gross_30d, + received_qty_30d = EXCLUDED.received_qty_30d, received_cost_30d = EXCLUDED.received_cost_30d, + lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, + first_7_days_sales = EXCLUDED.first_7_days_sales, first_7_days_revenue = EXCLUDED.first_7_days_revenue, first_30_days_sales = EXCLUDED.first_30_days_sales, first_30_days_revenue = EXCLUDED.first_30_days_revenue, + first_60_days_sales = EXCLUDED.first_60_days_sales, first_60_days_revenue = EXCLUDED.first_60_days_revenue, first_90_days_sales = EXCLUDED.first_90_days_sales, first_90_days_revenue = EXCLUDED.first_90_days_revenue, + asp_30d = EXCLUDED.asp_30d, acp_30d = EXCLUDED.acp_30d, avg_ros_30d = EXCLUDED.avg_ros_30d, avg_sales_per_day_30d = EXCLUDED.avg_sales_per_day_30d, + margin_30d = EXCLUDED.margin_30d, markup_30d = EXCLUDED.markup_30d, gmroi_30d = EXCLUDED.gmroi_30d, stockturn_30d = EXCLUDED.stockturn_30d, return_rate_30d = EXCLUDED.return_rate_30d, discount_rate_30d = EXCLUDED.discount_rate_30d, + stockout_rate_30d = EXCLUDED.stockout_rate_30d, markdown_30d = EXCLUDED.markdown_30d, markdown_rate_30d = EXCLUDED.markdown_rate_30d, sell_through_30d = EXCLUDED.sell_through_30d, + avg_lead_time_days = EXCLUDED.avg_lead_time_days, abc_class = EXCLUDED.abc_class, + sales_velocity_daily = EXCLUDED.sales_velocity_daily, config_lead_time = EXCLUDED.config_lead_time, config_days_of_stock = EXCLUDED.config_days_of_stock, config_safety_stock = EXCLUDED.config_safety_stock, + planning_period_days = EXCLUDED.planning_period_days, lead_time_forecast_units = EXCLUDED.lead_time_forecast_units, days_of_stock_forecast_units = EXCLUDED.days_of_stock_forecast_units, + planning_period_forecast_units = EXCLUDED.planning_period_forecast_units, lead_time_closing_stock = EXCLUDED.lead_time_closing_stock, days_of_stock_closing_stock = EXCLUDED.days_of_stock_closing_stock, + replenishment_needed_raw = EXCLUDED.replenishment_needed_raw, replenishment_units = EXCLUDED.replenishment_units, replenishment_cost = EXCLUDED.replenishment_cost, replenishment_retail = EXCLUDED.replenishment_retail, replenishment_profit = EXCLUDED.replenishment_profit, + to_order_units = EXCLUDED.to_order_units, -- *** Update to use EXCLUDED *** + forecast_lost_sales_units = EXCLUDED.forecast_lost_sales_units, forecast_lost_revenue = EXCLUDED.forecast_lost_revenue, + stock_cover_in_days = EXCLUDED.stock_cover_in_days, po_cover_in_days = EXCLUDED.po_cover_in_days, sells_out_in_days = EXCLUDED.sells_out_in_days, replenish_date = EXCLUDED.replenish_date, + overstocked_units = EXCLUDED.overstocked_units, overstocked_cost = EXCLUDED.overstocked_cost, overstocked_retail = EXCLUDED.overstocked_retail, is_old_stock = EXCLUDED.is_old_stock, + yesterday_sales = EXCLUDED.yesterday_sales; + RAISE NOTICE 'Finished % module. Duration: %', _module_name, clock_timestamp() - _start_time; +END $$; \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/backfill/rebuild_daily_snapshots.sql b/inventory-server/scripts/metrics-new/backfill/rebuild_daily_snapshots.sql new file mode 100644 index 0000000..dceb6f6 --- /dev/null +++ b/inventory-server/scripts/metrics-new/backfill/rebuild_daily_snapshots.sql @@ -0,0 +1,152 @@ +-- Description: Rebuilds daily product snapshots from scratch using real orders data. +-- Fixes issues with duplicated/inflated metrics. +-- Dependencies: Core import tables (products, orders, receivings). +-- Frequency: One-time run to clear out problematic data. + +DO $$ +DECLARE + _module_name TEXT := 'rebuild_daily_snapshots'; + _start_time TIMESTAMPTZ := clock_timestamp(); + _date DATE; + _count INT; + _total_records INT := 0; + _begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2024-01-01'); -- Starting point for data rebuild + _end_date DATE := CURRENT_DATE; +BEGIN + RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time; + + -- First truncate the existing snapshots to ensure a clean slate + TRUNCATE TABLE public.daily_product_snapshots; + RAISE NOTICE 'Cleared existing snapshot data'; + + -- Now rebuild the snapshots day by day + _date := _begin_date; + + WHILE _date <= _end_date LOOP + RAISE NOTICE 'Processing date %...', _date; + + -- Create snapshots for this date + WITH SalesData AS ( + SELECT + p.pid, + p.sku, + -- Count orders to ensure we only include products with real activity + COUNT(o.id) as order_count, + -- Aggregate Sales (Quantity > 0, Status not Canceled/Returned) + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue, + + -- Aggregate Returns (Quantity < 0 or Status = Returned) + COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned, + COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue + FROM public.products p + LEFT JOIN public.orders o + ON p.pid = o.pid + AND o.date::date = _date + GROUP BY p.pid, p.sku + HAVING COUNT(o.id) > 0 -- Only include products with actual orders for this date + ), + ReceivingData AS ( + SELECT + r.pid, + -- Count receiving documents to ensure we only include products with real activity + COUNT(DISTINCT r.receiving_id) as receiving_count, + -- Calculate received quantity for this day + SUM(r.qty_each) AS units_received, + -- Calculate received cost for this day + SUM(r.qty_each * r.cost_each) AS cost_received + FROM public.receivings r + WHERE r.received_date::date = _date + GROUP BY r.pid + HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0 + ), + -- Get stock quantities for the day - note this is approximate since we're using current products data + StockData AS ( + SELECT + p.pid, + p.stock_quantity, + COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price, + COALESCE(p.price, 0.00) as current_price, + COALESCE(p.regular_price, 0.00) as current_regular_price + FROM public.products p + ) + INSERT INTO public.daily_product_snapshots ( + snapshot_date, + pid, + sku, + eod_stock_quantity, + eod_stock_cost, + eod_stock_retail, + eod_stock_gross, + stockout_flag, + units_sold, + units_returned, + gross_revenue, + discounts, + returns_revenue, + net_revenue, + cogs, + gross_regular_revenue, + profit, + units_received, + cost_received, + calculation_timestamp + ) + SELECT + _date AS snapshot_date, + COALESCE(sd.pid, rd.pid) AS pid, + sd.sku, + -- Use current stock as approximation, since historical stock data may not be available + s.stock_quantity AS eod_stock_quantity, + s.stock_quantity * s.effective_cost_price AS eod_stock_cost, + s.stock_quantity * s.current_price AS eod_stock_retail, + s.stock_quantity * s.current_regular_price AS eod_stock_gross, + (s.stock_quantity <= 0) AS stockout_flag, + -- Sales metrics + COALESCE(sd.units_sold, 0), + COALESCE(sd.units_returned, 0), + COALESCE(sd.gross_revenue_unadjusted, 0.00), + COALESCE(sd.discounts, 0.00), + COALESCE(sd.returns_revenue, 0.00), + COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue, + COALESCE(sd.cogs, 0.00), + COALESCE(sd.gross_regular_revenue, 0.00), + (COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, + -- Receiving metrics + COALESCE(rd.units_received, 0), + COALESCE(rd.cost_received, 0.00), + _start_time + FROM SalesData sd + FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid + LEFT JOIN StockData s ON COALESCE(sd.pid, rd.pid) = s.pid + WHERE (COALESCE(sd.order_count, 0) > 0 OR COALESCE(rd.receiving_count, 0) > 0); + + -- Get record count for this day + GET DIAGNOSTICS _count = ROW_COUNT; + _total_records := _total_records + _count; + + RAISE NOTICE 'Added % snapshot records for date %', _count, _date; + + -- Move to next day + _date := _date + INTERVAL '1 day'; + END LOOP; + + RAISE NOTICE 'Rebuilding daily snapshots complete. Added % total records across % days.', _total_records, (_end_date - _begin_date)::integer + 1; + + -- Update the status table for daily_snapshots + INSERT INTO public.calculate_status (module_name, last_calculation_timestamp) + VALUES ('daily_snapshots', _start_time) + ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time; + + -- Now update product_metrics based on the rebuilt snapshots + RAISE NOTICE 'Triggering update of product_metrics table...'; + + -- Call the update_product_metrics procedure directly + -- Your system might use a different method to trigger this update + PERFORM pg_notify('recalculate_metrics', 'product_metrics'); + + RAISE NOTICE 'Rebuild complete. Duration: %', clock_timestamp() - _start_time; +END $$; \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/calculate_brand_metrics.sql b/inventory-server/scripts/metrics-new/calculate_brand_metrics.sql index 84ca91f..f0fd505 100644 --- a/inventory-server/scripts/metrics-new/calculate_brand_metrics.sql +++ b/inventory-server/scripts/metrics-new/calculate_brand_metrics.sql @@ -42,6 +42,20 @@ BEGIN JOIN public.products p ON pm.pid = p.pid GROUP BY brand_group ), + PreviousPeriodBrandMetrics AS ( + -- Get previous period metrics for growth calculation + SELECT + COALESCE(p.brand, 'Unbranded') AS brand_group, + SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days' + AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days' + THEN dps.units_sold ELSE 0 END) AS sales_prev_30d, + SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days' + AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days' + THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d + FROM public.daily_product_snapshots dps + JOIN public.products p ON dps.pid = p.pid + GROUP BY brand_group + ), AllBrands AS ( -- Ensure all brands from products table are included, mapping NULL/empty to 'Unbranded' SELECT DISTINCT COALESCE(brand, 'Unbranded') as brand_group @@ -53,7 +67,8 @@ BEGIN current_stock_units, current_stock_cost, current_stock_retail, sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d, sales_365d, revenue_365d, lifetime_sales, lifetime_revenue, - avg_margin_30d + avg_margin_30d, + sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev ) SELECT b.brand_group, @@ -78,9 +93,13 @@ BEGIN -- This is mathematically equivalent to profit/revenue but more explicit ((COALESCE(ba.revenue_30d, 0) - COALESCE(ba.cogs_30d, 0)) / COALESCE(ba.revenue_30d, 1)) * 100.0 ELSE NULL -- No margin for low/no revenue brands - END + END, + -- Growth metrics + std_numeric(safe_divide((ba.sales_30d - ppbm.sales_prev_30d) * 100.0, ppbm.sales_prev_30d), 2), + std_numeric(safe_divide((ba.revenue_30d - ppbm.revenue_prev_30d) * 100.0, ppbm.revenue_prev_30d), 2) FROM AllBrands b LEFT JOIN BrandAggregates ba ON b.brand_group = ba.brand_group + LEFT JOIN PreviousPeriodBrandMetrics ppbm ON b.brand_group = ppbm.brand_group ON CONFLICT (brand_name) DO UPDATE SET last_calculated = EXCLUDED.last_calculated, @@ -95,7 +114,9 @@ BEGIN profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, - avg_margin_30d = EXCLUDED.avg_margin_30d + avg_margin_30d = EXCLUDED.avg_margin_30d, + sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev, + revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev WHERE -- Only update if at least one value has changed brand_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR brand_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR diff --git a/inventory-server/scripts/metrics-new/calculate_category_metrics.sql b/inventory-server/scripts/metrics-new/calculate_category_metrics.sql index ae7f549..a55d2ef 100644 --- a/inventory-server/scripts/metrics-new/calculate_category_metrics.sql +++ b/inventory-server/scripts/metrics-new/calculate_category_metrics.sql @@ -1,5 +1,5 @@ --- Description: Calculates and updates aggregated metrics per category. --- Dependencies: product_metrics, products, categories, product_categories, calculate_status table. +-- Description: Calculates and updates aggregated metrics per category with hierarchy rollups. +-- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table. -- Frequency: Daily (after product_metrics update). DO $$ @@ -9,55 +9,21 @@ DECLARE _min_revenue NUMERIC := 50.00; -- Minimum revenue threshold for margin calculation BEGIN RAISE NOTICE 'Running % calculation...', _module_name; + + -- Refresh the category hierarchy materialized view first + REFRESH MATERIALIZED VIEW CONCURRENTLY category_hierarchy; - WITH - -- Identify the hierarchy depth for each category - CategoryDepth AS ( - WITH RECURSIVE CategoryTree AS ( - -- Base case: Start with categories without parents (root categories) - SELECT cat_id, name, parent_id, 0 AS depth - FROM public.categories - WHERE parent_id IS NULL - - UNION ALL - - -- Recursive step: Add child categories with incremented depth - SELECT c.cat_id, c.name, c.parent_id, ct.depth + 1 - FROM public.categories c - JOIN CategoryTree ct ON c.parent_id = ct.cat_id - ) - SELECT cat_id, depth - FROM CategoryTree - ), - -- For each product, find the most specific (deepest) category it belongs to - ProductDeepestCategory AS ( - SELECT - pc.pid, - pc.cat_id - FROM public.product_categories pc - JOIN CategoryDepth cd ON pc.cat_id = cd.cat_id - -- This is the key part: for each product, select only the category with maximum depth - WHERE (pc.pid, cd.depth) IN ( - SELECT pc2.pid, MAX(cd2.depth) - FROM public.product_categories pc2 - JOIN CategoryDepth cd2 ON pc2.cat_id = cd2.cat_id - GROUP BY pc2.pid - ) - ), - -- Calculate metrics only at the most specific category level for each product - -- These are the direct metrics (only products directly in this category) - DirectCategoryMetrics AS ( + -- First calculate direct metrics (products directly in each category) + WITH DirectCategoryMetrics AS ( SELECT - pdc.cat_id, - -- Counts + pc.cat_id, COUNT(DISTINCT pm.pid) AS product_count, COUNT(DISTINCT CASE WHEN pm.is_visible THEN pm.pid END) AS active_product_count, COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN pm.pid END) AS replenishable_product_count, - -- Current Stock SUM(pm.current_stock) AS current_stock_units, SUM(pm.current_stock_cost) AS current_stock_cost, SUM(pm.current_stock_retail) AS current_stock_retail, - -- Rolling Periods - Only include products with actual sales in each period + -- Sales metrics with proper filtering SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d, SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d, SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d, @@ -67,179 +33,141 @@ BEGIN SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d, SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d, SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales, - SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue, - -- Data for KPIs - Only average stock for products with stock - SUM(CASE WHEN pm.avg_stock_units_30d > 0 THEN pm.avg_stock_units_30d ELSE 0 END) AS total_avg_stock_units_30d - FROM public.product_metrics pm - JOIN ProductDeepestCategory pdc ON pm.pid = pdc.pid - GROUP BY pdc.cat_id + SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue + FROM public.product_categories pc + JOIN public.product_metrics pm ON pc.pid = pm.pid + GROUP BY pc.cat_id ), - -- Build a category lookup table for parent relationships - CategoryHierarchyPaths AS ( - WITH RECURSIVE ParentPaths AS ( - -- Base case: All categories with their immediate parents - SELECT - cat_id, - cat_id as leaf_id, -- Every category is its own leaf initially - ARRAY[cat_id] as path - FROM public.categories - - UNION ALL - - -- Recursive step: Walk up the parent chain - SELECT - c.parent_id as cat_id, - pp.leaf_id, -- Keep the original leaf_id - c.parent_id || pp.path as path - FROM ParentPaths pp - JOIN public.categories c ON pp.cat_id = c.cat_id - WHERE c.parent_id IS NOT NULL -- Stop at root categories - ) - -- Select distinct paths to avoid duplication - SELECT DISTINCT cat_id, leaf_id - FROM ParentPaths - ), - -- Aggregate metrics from leaf categories to their ancestors without duplication - -- These are the rolled-up metrics (including all child categories) - RollupMetrics AS ( + -- Calculate rolled-up metrics (including all descendant categories) + RolledUpMetrics AS ( SELECT - chp.cat_id, - -- For each parent category, count distinct products to avoid duplication - COUNT(DISTINCT dcm.cat_id) AS child_categories_count, - SUM(dcm.product_count) AS rollup_product_count, - SUM(dcm.active_product_count) AS rollup_active_product_count, - SUM(dcm.replenishable_product_count) AS rollup_replenishable_product_count, - SUM(dcm.current_stock_units) AS rollup_current_stock_units, - SUM(dcm.current_stock_cost) AS rollup_current_stock_cost, - SUM(dcm.current_stock_retail) AS rollup_current_stock_retail, - SUM(dcm.sales_7d) AS rollup_sales_7d, - SUM(dcm.revenue_7d) AS rollup_revenue_7d, - SUM(dcm.sales_30d) AS rollup_sales_30d, - SUM(dcm.revenue_30d) AS rollup_revenue_30d, - SUM(dcm.cogs_30d) AS rollup_cogs_30d, - SUM(dcm.profit_30d) AS rollup_profit_30d, - SUM(dcm.sales_365d) AS rollup_sales_365d, - SUM(dcm.revenue_365d) AS rollup_revenue_365d, - SUM(dcm.lifetime_sales) AS rollup_lifetime_sales, - SUM(dcm.lifetime_revenue) AS rollup_lifetime_revenue, - SUM(dcm.total_avg_stock_units_30d) AS rollup_total_avg_stock_units_30d - FROM CategoryHierarchyPaths chp - JOIN DirectCategoryMetrics dcm ON chp.leaf_id = dcm.cat_id - GROUP BY chp.cat_id + ch.cat_id, + -- Sum metrics from this category and all its descendants + SUM(dcm.product_count) AS product_count, + SUM(dcm.active_product_count) AS active_product_count, + SUM(dcm.replenishable_product_count) AS replenishable_product_count, + SUM(dcm.current_stock_units) AS current_stock_units, + SUM(dcm.current_stock_cost) AS current_stock_cost, + SUM(dcm.current_stock_retail) AS current_stock_retail, + SUM(dcm.sales_7d) AS sales_7d, + SUM(dcm.revenue_7d) AS revenue_7d, + SUM(dcm.sales_30d) AS sales_30d, + SUM(dcm.revenue_30d) AS revenue_30d, + SUM(dcm.cogs_30d) AS cogs_30d, + SUM(dcm.profit_30d) AS profit_30d, + SUM(dcm.sales_365d) AS sales_365d, + SUM(dcm.revenue_365d) AS revenue_365d, + SUM(dcm.lifetime_sales) AS lifetime_sales, + SUM(dcm.lifetime_revenue) AS lifetime_revenue + FROM category_hierarchy ch + LEFT JOIN DirectCategoryMetrics dcm ON + dcm.cat_id = ch.cat_id OR + dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids)) + GROUP BY ch.cat_id ), - -- Combine direct and rollup metrics - CombinedMetrics AS ( + PreviousPeriodCategoryMetrics AS ( + -- Get previous period metrics for growth calculation SELECT + pc.cat_id, + SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days' + AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days' + THEN dps.units_sold ELSE 0 END) AS sales_prev_30d, + SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days' + AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days' + THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d + FROM public.daily_product_snapshots dps + JOIN public.product_categories pc ON dps.pid = pc.pid + GROUP BY pc.cat_id + ), + RolledUpPreviousPeriod AS ( + -- Calculate rolled-up previous period metrics + SELECT + ch.cat_id, + SUM(ppcm.sales_prev_30d) AS sales_prev_30d, + SUM(ppcm.revenue_prev_30d) AS revenue_prev_30d + FROM category_hierarchy ch + LEFT JOIN PreviousPeriodCategoryMetrics ppcm ON + ppcm.cat_id = ch.cat_id OR + ppcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids)) + GROUP BY ch.cat_id + ), + AllCategories AS ( + -- Ensure all categories are included + SELECT c.cat_id, c.name, c.type, - c.parent_id, - -- Direct metrics (just this category) - COALESCE(dcm.product_count, 0) AS direct_product_count, - COALESCE(dcm.active_product_count, 0) AS direct_active_product_count, - COALESCE(dcm.replenishable_product_count, 0) AS direct_replenishable_product_count, - COALESCE(dcm.current_stock_units, 0) AS direct_current_stock_units, - COALESCE(dcm.current_stock_cost, 0) AS direct_current_stock_cost, - COALESCE(dcm.current_stock_retail, 0) AS direct_current_stock_retail, - COALESCE(dcm.sales_7d, 0) AS direct_sales_7d, - COALESCE(dcm.revenue_7d, 0) AS direct_revenue_7d, - COALESCE(dcm.sales_30d, 0) AS direct_sales_30d, - COALESCE(dcm.revenue_30d, 0) AS direct_revenue_30d, - COALESCE(dcm.cogs_30d, 0) AS direct_cogs_30d, - COALESCE(dcm.profit_30d, 0) AS direct_profit_30d, - COALESCE(dcm.sales_365d, 0) AS direct_sales_365d, - COALESCE(dcm.revenue_365d, 0) AS direct_revenue_365d, - COALESCE(dcm.lifetime_sales, 0) AS direct_lifetime_sales, - COALESCE(dcm.lifetime_revenue, 0) AS direct_lifetime_revenue, - COALESCE(dcm.total_avg_stock_units_30d, 0) AS direct_avg_stock_units_30d, - - -- Rolled up metrics (this category + all children) - COALESCE(rm.rollup_product_count, 0) AS product_count, - COALESCE(rm.rollup_active_product_count, 0) AS active_product_count, - COALESCE(rm.rollup_replenishable_product_count, 0) AS replenishable_product_count, - COALESCE(rm.rollup_current_stock_units, 0) AS current_stock_units, - COALESCE(rm.rollup_current_stock_cost, 0) AS current_stock_cost, - COALESCE(rm.rollup_current_stock_retail, 0) AS current_stock_retail, - COALESCE(rm.rollup_sales_7d, 0) AS sales_7d, - COALESCE(rm.rollup_revenue_7d, 0) AS revenue_7d, - COALESCE(rm.rollup_sales_30d, 0) AS sales_30d, - COALESCE(rm.rollup_revenue_30d, 0) AS revenue_30d, - COALESCE(rm.rollup_cogs_30d, 0) AS cogs_30d, - COALESCE(rm.rollup_profit_30d, 0) AS profit_30d, - COALESCE(rm.rollup_sales_365d, 0) AS sales_365d, - COALESCE(rm.rollup_revenue_365d, 0) AS revenue_365d, - COALESCE(rm.rollup_lifetime_sales, 0) AS lifetime_sales, - COALESCE(rm.rollup_lifetime_revenue, 0) AS lifetime_revenue, - COALESCE(rm.rollup_total_avg_stock_units_30d, 0) AS total_avg_stock_units_30d + c.parent_id FROM public.categories c - LEFT JOIN DirectCategoryMetrics dcm ON c.cat_id = dcm.cat_id - LEFT JOIN RollupMetrics rm ON c.cat_id = rm.cat_id + WHERE c.status = 'active' ) INSERT INTO public.category_metrics ( category_id, category_name, category_type, parent_id, last_calculated, - -- Store all direct and rolled up metrics + -- Rolled-up metrics product_count, active_product_count, replenishable_product_count, current_stock_units, current_stock_cost, current_stock_retail, sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d, sales_365d, revenue_365d, lifetime_sales, lifetime_revenue, - -- Also store direct metrics with direct_ prefix + -- Direct metrics direct_product_count, direct_active_product_count, direct_replenishable_product_count, direct_current_stock_units, direct_stock_cost, direct_stock_retail, - direct_sales_7d, direct_revenue_7d, direct_sales_30d, direct_revenue_30d, + direct_sales_7d, direct_revenue_7d, direct_sales_30d, direct_revenue_30d, direct_profit_30d, direct_cogs_30d, direct_sales_365d, direct_revenue_365d, direct_lifetime_sales, direct_lifetime_revenue, -- KPIs - avg_margin_30d, stock_turn_30d + avg_margin_30d, + sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev ) SELECT - cm.cat_id, - cm.name, - cm.type, - cm.parent_id, + ac.cat_id, + ac.name, + ac.type, + ac.parent_id, _start_time, - -- Rolled-up metrics (total including children) - cm.product_count, - cm.active_product_count, - cm.replenishable_product_count, - cm.current_stock_units, - cm.current_stock_cost, - cm.current_stock_retail, - cm.sales_7d, cm.revenue_7d, - cm.sales_30d, cm.revenue_30d, cm.profit_30d, cm.cogs_30d, - cm.sales_365d, cm.revenue_365d, - cm.lifetime_sales, cm.lifetime_revenue, - -- Direct metrics (just this category) - cm.direct_product_count, - cm.direct_active_product_count, - cm.direct_replenishable_product_count, - cm.direct_current_stock_units, - cm.direct_current_stock_cost, - cm.direct_current_stock_retail, - cm.direct_sales_7d, cm.direct_revenue_7d, - cm.direct_sales_30d, cm.direct_revenue_30d, cm.direct_profit_30d, cm.direct_cogs_30d, - cm.direct_sales_365d, cm.direct_revenue_365d, - cm.direct_lifetime_sales, cm.direct_lifetime_revenue, + -- Rolled-up metrics (includes descendants) + COALESCE(rum.product_count, 0), + COALESCE(rum.active_product_count, 0), + COALESCE(rum.replenishable_product_count, 0), + COALESCE(rum.current_stock_units, 0), + COALESCE(rum.current_stock_cost, 0.00), + COALESCE(rum.current_stock_retail, 0.00), + COALESCE(rum.sales_7d, 0), COALESCE(rum.revenue_7d, 0.00), + COALESCE(rum.sales_30d, 0), COALESCE(rum.revenue_30d, 0.00), + COALESCE(rum.profit_30d, 0.00), COALESCE(rum.cogs_30d, 0.00), + COALESCE(rum.sales_365d, 0), COALESCE(rum.revenue_365d, 0.00), + COALESCE(rum.lifetime_sales, 0), COALESCE(rum.lifetime_revenue, 0.00), + -- Direct metrics (only this category) + COALESCE(dcm.product_count, 0), + COALESCE(dcm.active_product_count, 0), + COALESCE(dcm.replenishable_product_count, 0), + COALESCE(dcm.current_stock_units, 0), + COALESCE(dcm.current_stock_cost, 0.00), + COALESCE(dcm.current_stock_retail, 0.00), + COALESCE(dcm.sales_7d, 0), COALESCE(dcm.revenue_7d, 0.00), + COALESCE(dcm.sales_30d, 0), COALESCE(dcm.revenue_30d, 0.00), + COALESCE(dcm.profit_30d, 0.00), COALESCE(dcm.cogs_30d, 0.00), + COALESCE(dcm.sales_365d, 0), COALESCE(dcm.revenue_365d, 0.00), + COALESCE(dcm.lifetime_sales, 0), COALESCE(dcm.lifetime_revenue, 0.00), -- KPIs - Calculate margin only for categories with significant revenue CASE - WHEN cm.revenue_30d >= _min_revenue THEN - ((cm.revenue_30d - cm.cogs_30d) / cm.revenue_30d) * 100.0 - ELSE NULL -- No margin for low/no revenue categories + WHEN COALESCE(rum.revenue_30d, 0) >= _min_revenue THEN + ((COALESCE(rum.revenue_30d, 0) - COALESCE(rum.cogs_30d, 0)) / COALESCE(rum.revenue_30d, 1)) * 100.0 + ELSE NULL END, - -- Stock Turn calculation - CASE - WHEN cm.total_avg_stock_units_30d > 0 THEN - cm.sales_30d / cm.total_avg_stock_units_30d - ELSE NULL -- No stock turn if no average stock - END - FROM CombinedMetrics cm + -- Growth metrics for rolled-up values + std_numeric(safe_divide((rum.sales_30d - rupp.sales_prev_30d) * 100.0, rupp.sales_prev_30d), 2), + std_numeric(safe_divide((rum.revenue_30d - rupp.revenue_prev_30d) * 100.0, rupp.revenue_prev_30d), 2) + FROM AllCategories ac + LEFT JOIN DirectCategoryMetrics dcm ON ac.cat_id = dcm.cat_id + LEFT JOIN RolledUpMetrics rum ON ac.cat_id = rum.cat_id + LEFT JOIN RolledUpPreviousPeriod rupp ON ac.cat_id = rupp.cat_id ON CONFLICT (category_id) DO UPDATE SET + last_calculated = EXCLUDED.last_calculated, category_name = EXCLUDED.category_name, category_type = EXCLUDED.category_type, parent_id = EXCLUDED.parent_id, - last_calculated = EXCLUDED.last_calculated, - - -- ROLLED-UP METRICS (includes this category + all descendants) + -- Rolled-up metrics product_count = EXCLUDED.product_count, active_product_count = EXCLUDED.active_product_count, replenishable_product_count = EXCLUDED.replenishable_product_count, @@ -251,8 +179,7 @@ BEGIN profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, - - -- DIRECT METRICS (only products directly in this category) + -- Direct metrics direct_product_count = EXCLUDED.direct_product_count, direct_active_product_count = EXCLUDED.direct_active_product_count, direct_replenishable_product_count = EXCLUDED.direct_replenishable_product_count, @@ -264,10 +191,9 @@ BEGIN direct_profit_30d = EXCLUDED.direct_profit_30d, direct_cogs_30d = EXCLUDED.direct_cogs_30d, direct_sales_365d = EXCLUDED.direct_sales_365d, direct_revenue_365d = EXCLUDED.direct_revenue_365d, direct_lifetime_sales = EXCLUDED.direct_lifetime_sales, direct_lifetime_revenue = EXCLUDED.direct_lifetime_revenue, - - -- Calculated KPIs avg_margin_30d = EXCLUDED.avg_margin_30d, - stock_turn_30d = EXCLUDED.stock_turn_30d + sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev, + revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev WHERE -- Only update if at least one value has changed category_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR category_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR @@ -291,19 +217,23 @@ WITH update_stats AS ( SELECT COUNT(*) as total_categories, COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed, - COUNT(*) FILTER (WHERE category_type = 11) as main_categories, -- 11 = category - COUNT(*) FILTER (WHERE category_type = 12) as subcategories, -- 12 = subcategory - SUM(product_count) as total_products, - SUM(active_product_count) as total_active_products, - SUM(current_stock_units) as total_stock_units + COUNT(*) FILTER (WHERE category_type = 10) as sections, + COUNT(*) FILTER (WHERE category_type = 11) as categories, + COUNT(*) FILTER (WHERE category_type = 12) as subcategories, + SUM(product_count) as total_products_rolled, + SUM(direct_product_count) as total_products_direct, + SUM(sales_30d) as total_sales_30d, + SUM(revenue_30d) as total_revenue_30d FROM public.category_metrics ) SELECT rows_processed, total_categories, - main_categories, + sections, + categories, subcategories, - total_products::int, - total_active_products::int, - total_stock_units::int + total_products_rolled::int, + total_products_direct::int, + total_sales_30d::int, + ROUND(total_revenue_30d, 2) as total_revenue_30d FROM update_stats; \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/calculate_vendor_metrics.sql b/inventory-server/scripts/metrics-new/calculate_vendor_metrics.sql new file mode 100644 index 0000000..04eb2cf --- /dev/null +++ b/inventory-server/scripts/metrics-new/calculate_vendor_metrics.sql @@ -0,0 +1,185 @@ +-- Description: Calculates and updates aggregated metrics per vendor. +-- Dependencies: product_metrics, products, purchase_orders, calculate_status table. +-- Frequency: Daily (after product_metrics update). + +DO $$ +DECLARE + _module_name VARCHAR := 'vendor_metrics'; + _start_time TIMESTAMPTZ := clock_timestamp(); +BEGIN + RAISE NOTICE 'Running % calculation...', _module_name; + + WITH VendorProductAggregates AS ( + -- Aggregate metrics from product_metrics table per vendor + SELECT + p.vendor, + COUNT(DISTINCT pm.pid) AS product_count, + COUNT(DISTINCT CASE WHEN pm.is_visible THEN pm.pid END) AS active_product_count, + COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN pm.pid END) AS replenishable_product_count, + SUM(pm.current_stock) AS current_stock_units, + SUM(pm.current_stock_cost) AS current_stock_cost, + SUM(pm.current_stock_retail) AS current_stock_retail, + SUM(pm.on_order_qty) AS on_order_units, + SUM(pm.on_order_cost) AS on_order_cost, + -- Only include products with valid sales data in each time period + COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d, + SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d, + SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d, + + COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d, + SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d, + SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d, + SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d, + SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d, + + COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d, + SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d, + SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d, + + COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales, + SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales, + SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue + FROM public.product_metrics pm + JOIN public.products p ON pm.pid = p.pid + WHERE p.vendor IS NOT NULL AND p.vendor <> '' + GROUP BY p.vendor + ), + PreviousPeriodVendorMetrics AS ( + -- Get previous period metrics for growth calculation + SELECT + p.vendor, + SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days' + AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days' + THEN dps.units_sold ELSE 0 END) AS sales_prev_30d, + SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days' + AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days' + THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d + FROM public.daily_product_snapshots dps + JOIN public.products p ON dps.pid = p.pid + WHERE p.vendor IS NOT NULL AND p.vendor <> '' + GROUP BY p.vendor + ), + VendorPOAggregates AS ( + -- Aggregate PO related stats including lead time calculated from POs to receivings + SELECT + po.vendor, + COUNT(DISTINCT po.po_id) AS po_count_365d, + -- Calculate lead time by averaging the days between PO date and receiving date + AVG(GREATEST(1, CASE + WHEN r.received_date IS NOT NULL AND po.date IS NOT NULL + THEN (r.received_date::date - po.date::date) + ELSE NULL + END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs + FROM public.purchase_orders po + -- Join to receivings table to find when items were received + LEFT JOIN public.receivings r ON r.pid = po.pid + WHERE po.vendor IS NOT NULL AND po.vendor <> '' + AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year + AND po.status = 'done' -- Only calculate lead time on completed POs + AND r.received_date IS NOT NULL + AND po.date IS NOT NULL + AND r.received_date >= po.date + GROUP BY po.vendor + ), + AllVendors AS ( + -- Ensure all vendors from products table are included + SELECT DISTINCT vendor FROM public.products WHERE vendor IS NOT NULL AND vendor <> '' + ) + INSERT INTO public.vendor_metrics ( + vendor_name, last_calculated, + product_count, active_product_count, replenishable_product_count, + current_stock_units, current_stock_cost, current_stock_retail, + on_order_units, on_order_cost, + po_count_365d, avg_lead_time_days, + sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d, + sales_365d, revenue_365d, lifetime_sales, lifetime_revenue, + avg_margin_30d, + sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev + ) + SELECT + v.vendor, + _start_time, + -- Base Aggregates + COALESCE(vpa.product_count, 0), + COALESCE(vpa.active_product_count, 0), + COALESCE(vpa.replenishable_product_count, 0), + COALESCE(vpa.current_stock_units, 0), + COALESCE(vpa.current_stock_cost, 0.00), + COALESCE(vpa.current_stock_retail, 0.00), + COALESCE(vpa.on_order_units, 0), + COALESCE(vpa.on_order_cost, 0.00), + -- PO Aggregates + COALESCE(vpoa.po_count_365d, 0), + vpoa.avg_lead_time_days_hist, -- Can be NULL if no received POs + -- Sales Aggregates + COALESCE(vpa.sales_7d, 0), COALESCE(vpa.revenue_7d, 0.00), + COALESCE(vpa.sales_30d, 0), COALESCE(vpa.revenue_30d, 0.00), + COALESCE(vpa.profit_30d, 0.00), COALESCE(vpa.cogs_30d, 0.00), + COALESCE(vpa.sales_365d, 0), COALESCE(vpa.revenue_365d, 0.00), + COALESCE(vpa.lifetime_sales, 0), COALESCE(vpa.lifetime_revenue, 0.00), + -- KPIs + (vpa.profit_30d / NULLIF(vpa.revenue_30d, 0)) * 100.0, + -- Growth metrics + std_numeric(safe_divide((vpa.sales_30d - ppvm.sales_prev_30d) * 100.0, ppvm.sales_prev_30d), 2), + std_numeric(safe_divide((vpa.revenue_30d - ppvm.revenue_prev_30d) * 100.0, ppvm.revenue_prev_30d), 2) + FROM AllVendors v + LEFT JOIN VendorProductAggregates vpa ON v.vendor = vpa.vendor + LEFT JOIN VendorPOAggregates vpoa ON v.vendor = vpoa.vendor + LEFT JOIN PreviousPeriodVendorMetrics ppvm ON v.vendor = ppvm.vendor + + ON CONFLICT (vendor_name) DO UPDATE SET + last_calculated = EXCLUDED.last_calculated, + product_count = EXCLUDED.product_count, + active_product_count = EXCLUDED.active_product_count, + replenishable_product_count = EXCLUDED.replenishable_product_count, + current_stock_units = EXCLUDED.current_stock_units, + current_stock_cost = EXCLUDED.current_stock_cost, + current_stock_retail = EXCLUDED.current_stock_retail, + on_order_units = EXCLUDED.on_order_units, + on_order_cost = EXCLUDED.on_order_cost, + po_count_365d = EXCLUDED.po_count_365d, + avg_lead_time_days = EXCLUDED.avg_lead_time_days, + sales_7d = EXCLUDED.sales_7d, revenue_7d = EXCLUDED.revenue_7d, + sales_30d = EXCLUDED.sales_30d, revenue_30d = EXCLUDED.revenue_30d, + profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d, + sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, + lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, + avg_margin_30d = EXCLUDED.avg_margin_30d, + sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev, + revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev + WHERE -- Only update if at least one value has changed + vendor_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR + vendor_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR + vendor_metrics.current_stock_units IS DISTINCT FROM EXCLUDED.current_stock_units OR + vendor_metrics.on_order_units IS DISTINCT FROM EXCLUDED.on_order_units OR + vendor_metrics.sales_30d IS DISTINCT FROM EXCLUDED.sales_30d OR + vendor_metrics.revenue_30d IS DISTINCT FROM EXCLUDED.revenue_30d OR + vendor_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales; + + -- Update calculate_status + INSERT INTO public.calculate_status (module_name, last_calculation_timestamp) + VALUES (_module_name, _start_time) + ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time; + + RAISE NOTICE 'Finished % calculation. Duration: %', _module_name, clock_timestamp() - _start_time; +END $$; + +-- Return metrics about the update operation for tracking +WITH update_stats AS ( + SELECT + COUNT(*) as total_vendors, + COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed, + SUM(product_count) as total_products, + SUM(active_product_count) as total_active_products, + SUM(po_count_365d) as total_pos_365d, + AVG(avg_lead_time_days) as overall_avg_lead_time + FROM public.vendor_metrics +) +SELECT + rows_processed, + total_vendors, + total_products::int, + total_active_products::int, + total_pos_365d::int, + ROUND(overall_avg_lead_time, 1) as overall_avg_lead_time +FROM update_stats; \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/update_daily_snapshots.sql b/inventory-server/scripts/metrics-new/update_daily_snapshots.sql new file mode 100644 index 0000000..ff05315 --- /dev/null +++ b/inventory-server/scripts/metrics-new/update_daily_snapshots.sql @@ -0,0 +1,222 @@ +-- Description: Calculates and updates daily aggregated product data for recent days. +-- Uses UPSERT (INSERT ON CONFLICT UPDATE) for idempotency. +-- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table. +-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes). + +DO $$ +DECLARE + _module_name TEXT := 'daily_snapshots'; + _start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started + _last_calc_time TIMESTAMPTZ; + _target_date DATE; -- Will be set in the loop + _total_records INT := 0; + _has_orders BOOLEAN := FALSE; + _process_days INT := 5; -- Number of days to check/process (today plus previous 4 days) + _day_counter INT; + _missing_days INT[] := ARRAY[]::INT[]; -- Array to store days with missing or incomplete data +BEGIN + -- Get the timestamp before the last successful run of this module + SELECT last_calculation_timestamp INTO _last_calc_time + FROM public.calculate_status + WHERE module_name = _module_name; + + RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time; + + -- First, check which days need processing by comparing orders data with snapshot data + FOR _day_counter IN 0..(_process_days-1) LOOP + _target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day'); + + -- Check if this date needs updating by comparing orders to snapshot data + -- If the date has orders but not enough snapshots, or if snapshots show zero sales but orders exist, it's incomplete + SELECT + CASE WHEN ( + -- We have orders for this date but not enough snapshots, or snapshots with wrong total + (EXISTS (SELECT 1 FROM public.orders WHERE date::date = _target_date) AND + ( + -- No snapshots exist for this date + NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) OR + -- Or snapshots show zero sales but orders exist + (SELECT COALESCE(SUM(units_sold), 0) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) = 0 OR + -- Or the count of snapshot records is significantly less than distinct products in orders + (SELECT COUNT(*) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) < + (SELECT COUNT(DISTINCT pid) FROM public.orders WHERE date::date = _target_date) * 0.8 + ) + ) + ) THEN TRUE ELSE FALSE END + INTO _has_orders; + + IF _has_orders THEN + -- This day needs processing - add to our array + _missing_days := _missing_days || _day_counter; + RAISE NOTICE 'Day % needs updating (incomplete or missing data)', _target_date; + END IF; + END LOOP; + + -- If no days need updating, exit early + IF array_length(_missing_days, 1) IS NULL THEN + RAISE NOTICE 'No days need updating - all snapshot data appears complete'; + + -- Still update the calculate_status to record this run + UPDATE public.calculate_status + SET last_calculation_timestamp = _start_time + WHERE module_name = _module_name; + + RETURN; + END IF; + + RAISE NOTICE 'Need to update % days with missing or incomplete data', array_length(_missing_days, 1); + + -- Process only the days that need updating + FOREACH _day_counter IN ARRAY _missing_days LOOP + _target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day'); + RAISE NOTICE 'Processing date: %', _target_date; + + -- IMPORTANT: First delete any existing data for this date to prevent duplication + DELETE FROM public.daily_product_snapshots + WHERE snapshot_date = _target_date; + + -- Proceed with calculating daily metrics only for products with actual activity + WITH SalesData AS ( + SELECT + p.pid, + p.sku, + -- Track number of orders to ensure we have real data + COUNT(o.id) as order_count, + -- Aggregate Sales (Quantity > 0, Status not Canceled/Returned) + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted, -- Before discount + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN + COALESCE( + o.costeach, -- First use order-specific cost if available + get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost + p.landing_cost_price, -- Fallback to landing cost + p.cost_price -- Final fallback to current cost + ) * o.quantity + ELSE 0 END), 0.00) AS cogs, + COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue, -- Use current regular price for simplicity here + + -- Aggregate Returns (Quantity < 0 or Status = Returned) + COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned, + COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue + FROM public.products p -- Start from products to include those with no orders today + JOIN public.orders o -- Changed to INNER JOIN to only process products with orders + ON p.pid = o.pid + AND o.date::date = _target_date -- Cast to date to ensure compatibility regardless of original type + GROUP BY p.pid, p.sku + -- No HAVING clause here - we always want to include all orders + ), + ReceivingData AS ( + SELECT + r.pid, + -- Track number of receiving docs to ensure we have real data + COUNT(DISTINCT r.receiving_id) as receiving_doc_count, + -- Sum the quantities received on this date + SUM(r.qty_each) AS units_received, + -- Calculate the cost received (qty * cost) + SUM(r.qty_each * r.cost_each) AS cost_received + FROM public.receivings r + WHERE r.received_date::date = _target_date + -- Optional: Filter out canceled receivings if needed + -- AND r.status <> 'canceled' + GROUP BY r.pid + -- Only include products with actual receiving activity + HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0 + ), + CurrentStock AS ( + -- Select current stock values directly from products table + SELECT + pid, + stock_quantity, + COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price, + COALESCE(price, 0.00) as current_price, + COALESCE(regular_price, 0.00) as current_regular_price + FROM public.products + ), + ProductsWithActivity AS ( + -- Quick pre-filter to only process products with activity + SELECT DISTINCT pid + FROM ( + SELECT pid FROM SalesData + UNION + SELECT pid FROM ReceivingData + ) a + ) + -- Now insert records, but ONLY for products with actual activity + INSERT INTO public.daily_product_snapshots ( + snapshot_date, + pid, + sku, + eod_stock_quantity, + eod_stock_cost, + eod_stock_retail, + eod_stock_gross, + stockout_flag, + units_sold, + units_returned, + gross_revenue, + discounts, + returns_revenue, + net_revenue, + cogs, + gross_regular_revenue, + profit, + units_received, + cost_received, + calculation_timestamp + ) + SELECT + _target_date AS snapshot_date, + COALESCE(sd.pid, rd.pid) AS pid, -- Use sales or receiving PID + COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table + -- Inventory Metrics (Using CurrentStock) + cs.stock_quantity AS eod_stock_quantity, + cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost, + cs.stock_quantity * cs.current_price AS eod_stock_retail, + cs.stock_quantity * cs.current_regular_price AS eod_stock_gross, + (cs.stock_quantity <= 0) AS stockout_flag, + -- Sales Metrics (From SalesData) + COALESCE(sd.units_sold, 0), + COALESCE(sd.units_returned, 0), + COALESCE(sd.gross_revenue_unadjusted, 0.00), + COALESCE(sd.discounts, 0.00), + COALESCE(sd.returns_revenue, 0.00), + COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue, + COALESCE(sd.cogs, 0.00), + COALESCE(sd.gross_regular_revenue, 0.00), + (COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS + -- Receiving Metrics (From ReceivingData) + COALESCE(rd.units_received, 0), + COALESCE(rd.cost_received, 0.00), + _start_time -- Timestamp of this calculation run + FROM SalesData sd + FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid + JOIN ProductsWithActivity pwa ON COALESCE(sd.pid, rd.pid) = pwa.pid + LEFT JOIN public.products p ON COALESCE(sd.pid, rd.pid) = p.pid + LEFT JOIN CurrentStock cs ON COALESCE(sd.pid, rd.pid) = cs.pid + WHERE p.pid IS NOT NULL; -- Ensure we only insert for existing products + + -- Get the total number of records inserted for this date + GET DIAGNOSTICS _total_records = ROW_COUNT; + RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date; + END LOOP; + + -- Update the status table with the timestamp from the START of this run + UPDATE public.calculate_status + SET last_calculation_timestamp = _start_time + WHERE module_name = _module_name; + + RAISE NOTICE 'Finished % processing for multiple dates. Duration: %', _module_name, clock_timestamp() - _start_time; + +END $$; + +-- Return the total records processed for tracking +SELECT + COUNT(*) as rows_processed, + COUNT(DISTINCT snapshot_date) as days_processed, + MIN(snapshot_date) as earliest_date, + MAX(snapshot_date) as latest_date, + SUM(units_sold) as total_units_sold, + SUM(units_received) as total_units_received +FROM public.daily_product_snapshots +WHERE calculation_timestamp >= (NOW() - INTERVAL '5 minutes'); -- Recent updates only \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/update_periodic_metrics.sql b/inventory-server/scripts/metrics-new/update_periodic_metrics.sql new file mode 100644 index 0000000..a8c11de --- /dev/null +++ b/inventory-server/scripts/metrics-new/update_periodic_metrics.sql @@ -0,0 +1,139 @@ +-- Description: Calculates metrics that don't need hourly updates, like ABC class +-- and average lead time. +-- Dependencies: product_metrics, purchase_orders, settings_global, calculate_status. +-- Frequency: Daily or Weekly (e.g., run via cron job overnight). + +DO $$ +DECLARE + _module_name TEXT := 'periodic_metrics'; + _start_time TIMESTAMPTZ := clock_timestamp(); + _last_calc_time TIMESTAMPTZ; + _abc_basis VARCHAR; + _abc_period INT; + _threshold_a NUMERIC; + _threshold_b NUMERIC; +BEGIN + -- Get the timestamp before the last successful run of this module + SELECT last_calculation_timestamp INTO _last_calc_time + FROM public.calculate_status + WHERE module_name = _module_name; + + RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time; + + -- 1. Calculate Average Lead Time + RAISE NOTICE 'Calculating Average Lead Time...'; + WITH LeadTimes AS ( + SELECT + po.pid, + -- Calculate lead time by looking at when items ordered on POs were received + AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days + FROM public.purchase_orders po + -- Join to receivings table to find actual receipts + JOIN public.receivings r ON r.pid = po.pid + WHERE po.status = 'done' -- Only include completed POs + AND r.received_date >= po.date -- Ensure received date is not before order date + -- Optional: add check to make sure receiving is related to PO if you have source_po_id + -- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL) + GROUP BY po.pid + ) + UPDATE public.product_metrics pm + SET avg_lead_time_days = lt.avg_days::int + FROM LeadTimes lt + WHERE pm.pid = lt.pid + AND pm.avg_lead_time_days IS DISTINCT FROM lt.avg_days::int; -- Only update if changed + RAISE NOTICE 'Finished Average Lead Time calculation.'; + + + -- 2. Calculate ABC Classification + RAISE NOTICE 'Calculating ABC Classification...'; + -- Get ABC settings + SELECT setting_value INTO _abc_basis FROM public.settings_global WHERE setting_key = 'abc_calculation_basis' LIMIT 1; + SELECT setting_value::numeric INTO _threshold_a FROM public.settings_global WHERE setting_key = 'abc_revenue_threshold_a' LIMIT 1; + SELECT setting_value::numeric INTO _threshold_b FROM public.settings_global WHERE setting_key = 'abc_revenue_threshold_b' LIMIT 1; + _abc_basis := COALESCE(_abc_basis, 'revenue_30d'); -- Default basis + _threshold_a := COALESCE(_threshold_a, 0.80); + _threshold_b := COALESCE(_threshold_b, 0.95); + + RAISE NOTICE 'Using ABC Basis: %, Threshold A: %, Threshold B: %', _abc_basis, _threshold_a, _threshold_b; + + WITH RankedProducts AS ( + SELECT + pid, + -- Dynamically select the metric based on setting + CASE _abc_basis + WHEN 'sales_30d' THEN COALESCE(sales_30d, 0) + WHEN 'lifetime_revenue' THEN COALESCE(lifetime_revenue, 0)::numeric -- Cast needed if different type + ELSE COALESCE(revenue_30d, 0) -- Default to revenue_30d + END AS metric_value + FROM public.product_metrics + WHERE is_replenishable = TRUE -- Typically only classify replenishable items + ), + Cumulative AS ( + SELECT + pid, + metric_value, + SUM(metric_value) OVER (ORDER BY metric_value DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) as cumulative_metric, + SUM(metric_value) OVER () as total_metric + FROM RankedProducts + WHERE metric_value > 0 -- Exclude items with no contribution + ) + UPDATE public.product_metrics pm + SET abc_class = + CASE + WHEN c.cumulative_metric / NULLIF(c.total_metric, 0) <= _threshold_a THEN 'A' + WHEN c.cumulative_metric / NULLIF(c.total_metric, 0) <= _threshold_b THEN 'B' + ELSE 'C' + END + FROM Cumulative c + WHERE pm.pid = c.pid + AND pm.abc_class IS DISTINCT FROM ( -- Only update if changed + CASE + WHEN c.cumulative_metric / NULLIF(c.total_metric, 0) <= _threshold_a THEN 'A' + WHEN c.cumulative_metric / NULLIF(c.total_metric, 0) <= _threshold_b THEN 'B' + ELSE 'C' + END); + + -- Set non-contributing or non-replenishable to 'C' or NULL if preferred + UPDATE public.product_metrics + SET abc_class = 'C' -- Or NULL + WHERE abc_class IS NULL AND is_replenishable = TRUE; -- Catch those with 0 metric value + + UPDATE public.product_metrics + SET abc_class = NULL -- Or 'N/A'? + WHERE is_replenishable = FALSE AND abc_class IS NOT NULL; -- Unclassify non-replenishable items + + + RAISE NOTICE 'Finished ABC Classification calculation.'; + + -- Add other periodic calculations here if needed (e.g., recalculating first/last dates) + + -- Update the status table with the timestamp from the START of this run + UPDATE public.calculate_status + SET last_calculation_timestamp = _start_time + WHERE module_name = _module_name; + + RAISE NOTICE 'Finished % module. Duration: %', _module_name, clock_timestamp() - _start_time; + +END $$; + +-- Return metrics about the update operation for tracking +WITH update_stats AS ( + SELECT + COUNT(*) as total_products, + COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed, + COUNT(*) FILTER (WHERE abc_class = 'A') as abc_a_count, + COUNT(*) FILTER (WHERE abc_class = 'B') as abc_b_count, + COUNT(*) FILTER (WHERE abc_class = 'C') as abc_c_count, + COUNT(*) FILTER (WHERE avg_lead_time_days IS NOT NULL) as products_with_lead_time, + AVG(avg_lead_time_days) as overall_avg_lead_time + FROM public.product_metrics +) +SELECT + rows_processed, + total_products, + abc_a_count, + abc_b_count, + abc_c_count, + products_with_lead_time, + ROUND(overall_avg_lead_time, 1) as overall_avg_lead_time +FROM update_stats; \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/update_product_metrics.sql b/inventory-server/scripts/metrics-new/update_product_metrics.sql new file mode 100644 index 0000000..2554c55 --- /dev/null +++ b/inventory-server/scripts/metrics-new/update_product_metrics.sql @@ -0,0 +1,609 @@ +-- Description: Calculates and updates the main product_metrics table based on current data +-- and aggregated daily snapshots. Uses UPSERT for idempotency. +-- Dependencies: Core import tables, daily_product_snapshots, configuration tables, calculate_status. +-- Frequency: Hourly (Run AFTER update_daily_snapshots.sql completes). + +DO $$ +DECLARE + _module_name TEXT := 'product_metrics'; + _start_time TIMESTAMPTZ := clock_timestamp(); + _last_calc_time TIMESTAMPTZ; + _current_date DATE := CURRENT_DATE; +BEGIN + -- Get the timestamp before the last successful run of this module + SELECT last_calculation_timestamp INTO _last_calc_time + FROM public.calculate_status + WHERE module_name = _module_name; + + RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time; + + -- Use CTEs to gather all necessary information + WITH CurrentInfo AS ( + SELECT + p.pid, + p.sku, + p.title, + p.brand, + p.vendor, + COALESCE(p.image_175, p.image) as image_url, + p.visible as is_visible, + p.replenishable as is_replenishable, + -- Add new product fields + p.barcode, + p.harmonized_tariff_code, + p.vendor_reference, + p.notions_reference, + p.line, + p.subline, + p.artist, + p.moq, + p.rating, + p.reviews, + p.weight, + p.length, + p.width, + p.height, + p.country_of_origin, + p.location, + p.baskets, + p.notifies, + p.preorder_count, + p.notions_inv_count, + COALESCE(p.price, 0.00) as current_price, + COALESCE(p.regular_price, 0.00) as current_regular_price, + COALESCE(p.cost_price, 0.00) as current_cost_price, + COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost + p.stock_quantity as current_stock, + p.created_at, + p.first_received, + p.date_last_sold, + p.total_sold as historical_total_sold, -- Add historical total_sold from products table + p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each) + FROM public.products p + ), + OnOrderInfo AS ( + SELECT + pid, + SUM(ordered) AS on_order_qty, + SUM(ordered * po_cost_price) AS on_order_cost, + MIN(expected_date) AS earliest_expected_date + FROM public.purchase_orders + WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started') + AND status NOT IN ('canceled', 'done') + GROUP BY pid + ), + HistoricalDates AS ( + -- Note: Calculating these MIN/MAX values hourly can be slow on large tables. + -- Consider calculating periodically or storing on products if import can populate them. + SELECT + p.pid, + MIN(o.date)::date AS date_first_sold, + MAX(o.date)::date AS max_order_date, -- Use MAX for potential recalc of date_last_sold + + -- For first received, use the new receivings table + MIN(r.received_date)::date AS date_first_received_calc, + + -- For last received, use the new receivings table + MAX(r.received_date)::date AS date_last_received_calc + FROM public.products p + LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned') + LEFT JOIN public.receivings r ON p.pid = r.pid + GROUP BY p.pid + ), + SnapshotAggregates AS ( + SELECT + pid, + -- Get the counts of all available data + COUNT(DISTINCT snapshot_date) AS available_days, + + -- Rolling periods with no time constraint - just sum everything we have + SUM(units_sold) AS total_units_sold, + SUM(net_revenue) AS total_net_revenue, + + -- Specific time windows using date range boundaries precisely + -- Use _current_date - INTERVAL '6 days' to include 7 days (today + 6 previous days) + -- This ensures we count exactly the right number of days in each period + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_7d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_7d, + + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_14d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_14d, + + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN cogs ELSE 0 END) AS cogs_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN profit ELSE 0 END) AS profit_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_returned ELSE 0 END) AS returns_units_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN returns_revenue ELSE 0 END) AS returns_revenue_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN discounts ELSE 0 END) AS discounts_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN gross_revenue ELSE 0 END) AS gross_revenue_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN gross_regular_revenue ELSE 0 END) AS gross_regular_revenue_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date AND stockout_flag THEN 1 ELSE 0 END) AS stockout_days_30d, + + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_365d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_365d, + + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_received ELSE 0 END) AS received_qty_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN cost_received ELSE 0 END) AS received_cost_30d, + + -- Averages for stock levels - only include dates within the specified period + AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_quantity END) AS avg_stock_units_30d, + AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_cost END) AS avg_stock_cost_30d, + AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_retail END) AS avg_stock_retail_30d, + AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_gross END) AS avg_stock_gross_30d, + + -- Lifetime - should match total values above + SUM(units_sold) AS lifetime_sales, + SUM(net_revenue) AS lifetime_revenue, + + -- Yesterday + SUM(CASE WHEN snapshot_date = _current_date - INTERVAL '1 day' THEN units_sold ELSE 0 END) as yesterday_sales + + FROM public.daily_product_snapshots + GROUP BY pid + ), + FirstPeriodMetrics AS ( + SELECT + pid, + date_first_sold, + SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '6 days' THEN units_sold ELSE 0 END) AS first_7_days_sales, + SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS first_7_days_revenue, + SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '29 days' THEN units_sold ELSE 0 END) AS first_30_days_sales, + SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '29 days' THEN net_revenue ELSE 0 END) AS first_30_days_revenue, + SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '59 days' THEN units_sold ELSE 0 END) AS first_60_days_sales, + SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '59 days' THEN net_revenue ELSE 0 END) AS first_60_days_revenue, + SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '89 days' THEN units_sold ELSE 0 END) AS first_90_days_sales, + SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '89 days' THEN net_revenue ELSE 0 END) AS first_90_days_revenue + FROM public.daily_product_snapshots ds + JOIN HistoricalDates hd USING(pid) + WHERE date_first_sold IS NOT NULL + AND snapshot_date >= date_first_sold + AND snapshot_date <= date_first_sold + INTERVAL '90 days' -- Limit scan range + GROUP BY pid, date_first_sold + ), + Settings AS ( + SELECT + p.pid, + COALESCE(sp.lead_time_days, sv.default_lead_time_days, (SELECT setting_value FROM settings_global WHERE setting_key = 'default_lead_time_days')::int, 14) AS effective_lead_time, + COALESCE(sp.days_of_stock, sv.default_days_of_stock, (SELECT setting_value FROM settings_global WHERE setting_key = 'default_days_of_stock')::int, 30) AS effective_days_of_stock, + COALESCE(sp.safety_stock, 0) AS effective_safety_stock, -- Assuming safety stock is units, not days from global for now + COALESCE(sp.exclude_from_forecast, FALSE) AS exclude_forecast + FROM public.products p + LEFT JOIN public.settings_product sp ON p.pid = sp.pid + LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor + ), + LifetimeRevenue AS ( + -- Calculate actual revenue from orders table + SELECT + o.pid, + SUM(o.price * o.quantity - COALESCE(o.discount, 0)) AS lifetime_revenue_from_orders, + SUM(o.quantity) AS lifetime_units_from_orders + FROM public.orders o + WHERE o.status NOT IN ('canceled', 'returned') + AND o.quantity > 0 + GROUP BY o.pid + ), + PreviousPeriodMetrics AS ( + -- Calculate metrics for previous 30-day period for growth comparison + SELECT + pid, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '59 days' + AND snapshot_date < _current_date - INTERVAL '29 days' + THEN units_sold ELSE 0 END) AS sales_prev_30d, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '59 days' + AND snapshot_date < _current_date - INTERVAL '29 days' + THEN net_revenue ELSE 0 END) AS revenue_prev_30d, + -- Year-over-year comparison + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '395 days' + AND snapshot_date < _current_date - INTERVAL '365 days' + THEN units_sold ELSE 0 END) AS sales_30d_last_year, + SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '395 days' + AND snapshot_date < _current_date - INTERVAL '365 days' + THEN net_revenue ELSE 0 END) AS revenue_30d_last_year + FROM public.daily_product_snapshots + GROUP BY pid + ), + DemandVariability AS ( + -- Calculate variance and standard deviation of daily sales + SELECT + pid, + COUNT(*) AS days_with_data, + AVG(units_sold) AS avg_daily_sales, + VARIANCE(units_sold) AS sales_variance, + STDDEV(units_sold) AS sales_std_dev, + -- Coefficient of variation + CASE + WHEN AVG(units_sold) > 0 THEN STDDEV(units_sold) / AVG(units_sold) + ELSE NULL + END AS sales_cv + FROM public.daily_product_snapshots + WHERE snapshot_date >= _current_date - INTERVAL '29 days' + AND snapshot_date <= _current_date + GROUP BY pid + ), + ServiceLevels AS ( + -- Calculate service level and fill rate metrics + SELECT + pid, + COUNT(*) FILTER (WHERE stockout_flag = true) AS stockout_incidents_30d, + COUNT(*) FILTER (WHERE stockout_flag = true AND units_sold > 0) AS lost_sales_incidents_30d, + -- Service level: percentage of days without stockouts + (1.0 - (COUNT(*) FILTER (WHERE stockout_flag = true)::NUMERIC / NULLIF(COUNT(*), 0))) * 100 AS service_level_30d, + -- Fill rate: units sold / (units sold + potential lost sales) + CASE + WHEN SUM(units_sold) > 0 THEN + (SUM(units_sold)::NUMERIC / + (SUM(units_sold) + SUM(CASE WHEN stockout_flag THEN units_sold * 0.2 ELSE 0 END))) * 100 + ELSE NULL + END AS fill_rate_30d + FROM public.daily_product_snapshots + WHERE snapshot_date >= _current_date - INTERVAL '29 days' + AND snapshot_date <= _current_date + GROUP BY pid + ), + SeasonalityAnalysis AS ( + -- Simple seasonality detection + SELECT + p.pid, + sp.seasonal_pattern, + sp.seasonality_index, + sp.peak_season + FROM products p + CROSS JOIN LATERAL detect_seasonal_pattern(p.pid) sp + ) + -- Final UPSERT into product_metrics + INSERT INTO public.product_metrics ( + pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable, + barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist, + moq, rating, reviews, weight, length, width, height, country_of_origin, location, + baskets, notifies, preorder_count, notions_inv_count, + current_price, current_regular_price, current_cost_price, current_landing_cost_price, + current_stock, current_stock_cost, current_stock_retail, current_stock_gross, + on_order_qty, on_order_cost, on_order_retail, earliest_expected_date, + date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days, + sales_7d, revenue_7d, sales_14d, revenue_14d, sales_30d, revenue_30d, cogs_30d, profit_30d, + returns_units_30d, returns_revenue_30d, discounts_30d, gross_revenue_30d, gross_regular_revenue_30d, + stockout_days_30d, sales_365d, revenue_365d, + avg_stock_units_30d, avg_stock_cost_30d, avg_stock_retail_30d, avg_stock_gross_30d, + received_qty_30d, received_cost_30d, + lifetime_sales, lifetime_revenue, lifetime_revenue_quality, + first_7_days_sales, first_7_days_revenue, first_30_days_sales, first_30_days_revenue, + first_60_days_sales, first_60_days_revenue, first_90_days_sales, first_90_days_revenue, + asp_30d, acp_30d, avg_ros_30d, avg_sales_per_day_30d, avg_sales_per_month_30d, + margin_30d, markup_30d, gmroi_30d, stockturn_30d, return_rate_30d, discount_rate_30d, + stockout_rate_30d, markdown_30d, markdown_rate_30d, sell_through_30d, + -- avg_lead_time_days, -- Calculated periodically + -- abc_class, -- Calculated periodically + sales_velocity_daily, config_lead_time, config_days_of_stock, config_safety_stock, + planning_period_days, lead_time_forecast_units, days_of_stock_forecast_units, + planning_period_forecast_units, lead_time_closing_stock, days_of_stock_closing_stock, + replenishment_needed_raw, replenishment_units, replenishment_cost, replenishment_retail, replenishment_profit, + to_order_units, forecast_lost_sales_units, forecast_lost_revenue, + stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date, + overstocked_units, overstocked_cost, overstocked_retail, is_old_stock, + yesterday_sales, + status, -- Add status field for calculated status + -- New fields + sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev, + sales_growth_yoy, revenue_growth_yoy, + sales_variance_30d, sales_std_dev_30d, sales_cv_30d, demand_pattern, + fill_rate_30d, stockout_incidents_30d, service_level_30d, lost_sales_incidents_30d, + seasonality_index, seasonal_pattern, peak_season + ) + SELECT + ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable, + ci.barcode, ci.harmonized_tariff_code, ci.vendor_reference, ci.notions_reference, ci.line, ci.subline, ci.artist, + ci.moq, ci.rating, ci.reviews, ci.weight, ci.length, ci.width, ci.height, ci.country_of_origin, ci.location, + ci.baskets, ci.notifies, ci.preorder_count, ci.notions_inv_count, + ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost, + ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price, + COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date, + ci.created_at::date, COALESCE(ci.first_received::date, hd.date_first_received_calc), hd.date_last_received_calc, hd.date_first_sold, COALESCE(ci.date_last_sold, hd.max_order_date), + CASE + WHEN ci.created_at IS NULL AND hd.date_first_sold IS NULL THEN 0 + WHEN ci.created_at IS NULL THEN (_current_date - hd.date_first_sold)::integer + WHEN hd.date_first_sold IS NULL THEN (_current_date - ci.created_at::date)::integer + ELSE (_current_date - LEAST(ci.created_at::date, hd.date_first_sold))::integer + END AS age_days, + sa.sales_7d, sa.revenue_7d, sa.sales_14d, sa.revenue_14d, sa.sales_30d, sa.revenue_30d, sa.cogs_30d, sa.profit_30d, + sa.returns_units_30d, sa.returns_revenue_30d, sa.discounts_30d, sa.gross_revenue_30d, sa.gross_regular_revenue_30d, + sa.stockout_days_30d, sa.sales_365d, sa.revenue_365d, + sa.avg_stock_units_30d, sa.avg_stock_cost_30d, sa.avg_stock_retail_30d, sa.avg_stock_gross_30d, + sa.received_qty_30d, sa.received_cost_30d, + -- Use total_sold from products table as the source of truth for lifetime sales + -- This includes all historical data from the production database + ci.historical_total_sold AS lifetime_sales, + -- Calculate lifetime revenue using actual historical prices where available + CASE + WHEN lr.lifetime_revenue_from_orders IS NOT NULL THEN + -- We have some order history - use it plus estimate for remaining + lr.lifetime_revenue_from_orders + + (GREATEST(0, ci.historical_total_sold - COALESCE(lr.lifetime_units_from_orders, 0)) * + COALESCE( + -- Use oldest known price from snapshots as proxy + (SELECT revenue_7d / NULLIF(sales_7d, 0) + FROM daily_product_snapshots + WHERE pid = ci.pid AND sales_7d > 0 + ORDER BY snapshot_date ASC + LIMIT 1), + ci.current_price + )) + ELSE + -- No order history - estimate using current price + ci.historical_total_sold * ci.current_price + END AS lifetime_revenue, + CASE + WHEN lr.lifetime_units_from_orders >= ci.historical_total_sold * 0.9 THEN 'exact' + WHEN lr.lifetime_units_from_orders >= ci.historical_total_sold * 0.5 THEN 'partial' + ELSE 'estimated' + END AS lifetime_revenue_quality, + fpm.first_7_days_sales, fpm.first_7_days_revenue, fpm.first_30_days_sales, fpm.first_30_days_revenue, + fpm.first_60_days_sales, fpm.first_60_days_revenue, fpm.first_90_days_sales, fpm.first_90_days_revenue, + sa.revenue_30d / NULLIF(sa.sales_30d, 0) AS asp_30d, + sa.cogs_30d / NULLIF(sa.sales_30d, 0) AS acp_30d, + sa.profit_30d / NULLIF(sa.sales_30d, 0) AS avg_ros_30d, + sa.sales_30d / 30.0 AS avg_sales_per_day_30d, + sa.sales_30d AS avg_sales_per_month_30d, -- Using 30d sales as proxy for month + (sa.profit_30d / NULLIF(sa.revenue_30d, 0)) * 100 AS margin_30d, + (sa.profit_30d / NULLIF(sa.cogs_30d, 0)) * 100 AS markup_30d, + sa.profit_30d / NULLIF(sa.avg_stock_cost_30d, 0) AS gmroi_30d, + sa.sales_30d / NULLIF(sa.avg_stock_units_30d, 0) AS stockturn_30d, + (sa.returns_units_30d / NULLIF(sa.sales_30d + sa.returns_units_30d, 0)) * 100 AS return_rate_30d, + (sa.discounts_30d / NULLIF(sa.gross_revenue_30d, 0)) * 100 AS discount_rate_30d, + (sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d, + sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d, + ((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d, + -- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received) + -- Approximating beginning inventory as current stock + units sold - units received + (sa.sales_30d / NULLIF( + ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d, + 0 + )) * 100 AS sell_through_30d, + + -- Forecasting intermediate values + -- Use the calculate_sales_velocity function instead of repetitive calculation + calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) AS sales_velocity_daily, + s.effective_lead_time AS config_lead_time, + s.effective_days_of_stock AS config_days_of_stock, + s.effective_safety_stock AS config_safety_stock, + (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_days, + + calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time AS lead_time_forecast_units, + + calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock AS days_of_stock_forecast_units, + + calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_forecast_units, + + (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time)) AS lead_time_closing_stock, + + ((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock) AS days_of_stock_closing_stock, + + ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw, + + -- Final Forecasting / Replenishment Metrics + CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units, + (CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost, + (CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail, + (CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit, + + -- To Order (Apply MOQ/UOM logic here if needed, otherwise equals replenishment) + CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units, + + GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) AS forecast_lost_sales_units, + GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue, + + ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS stock_cover_in_days, + COALESCE(ooi.on_order_qty, 0) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS po_cover_in_days, + (ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS sells_out_in_days, + + -- Replenish Date: Date when stock is projected to hit safety stock, minus lead time + CASE + WHEN calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) > 0 + THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int))::int - s.effective_lead_time + ELSE NULL + END AS replenish_date, + + GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))::int AS overstocked_units, + (GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost, + (GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail, + + -- Old Stock Flag + (ci.created_at::date < _current_date - INTERVAL '60 day') AND + (COALESCE(ci.date_last_sold, hd.max_order_date) IS NULL OR COALESCE(ci.date_last_sold, hd.max_order_date) < _current_date - INTERVAL '60 day') AND + (hd.date_last_received_calc IS NULL OR hd.date_last_received_calc < _current_date - INTERVAL '60 day') AND + COALESCE(ooi.on_order_qty, 0) = 0 + AS is_old_stock, + + sa.yesterday_sales, + + -- Calculate status using direct CASE statements (inline logic) + CASE + -- Non-replenishable items default to Healthy + WHEN NOT ci.is_replenishable THEN 'Healthy' + + -- Calculate lead time and thresholds + ELSE + CASE + -- Check for overstock first + WHEN GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock))) > 0 THEN 'Overstock' + + -- Check for Critical stock + WHEN ci.current_stock <= 0 OR + (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) <= 0 THEN 'Critical' + + WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical' + + -- Check for reorder soon + WHEN ((ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) + 7) THEN + CASE + WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical' + ELSE 'Reorder Soon' + END + + -- Check for 'At Risk' - old stock + WHEN (ci.created_at::date < _current_date - INTERVAL '60 day') AND + (COALESCE(ci.date_last_sold, hd.max_order_date) IS NULL OR COALESCE(ci.date_last_sold, hd.max_order_date) < _current_date - INTERVAL '60 day') AND + (hd.date_last_received_calc IS NULL OR hd.date_last_received_calc < _current_date - INTERVAL '60 day') AND + COALESCE(ooi.on_order_qty, 0) = 0 THEN 'At Risk' + + -- Check for 'At Risk' - hasn't sold in a long time + WHEN COALESCE(ci.date_last_sold, hd.max_order_date) IS NOT NULL + AND COALESCE(ci.date_last_sold, hd.max_order_date) < (_current_date - INTERVAL '90 days') + AND (CASE + WHEN ci.created_at IS NULL AND hd.date_first_sold IS NULL THEN 0 + WHEN ci.created_at IS NULL THEN (_current_date - hd.date_first_sold)::integer + WHEN hd.date_first_sold IS NULL THEN (_current_date - ci.created_at::date)::integer + ELSE (_current_date - LEAST(ci.created_at::date, hd.date_first_sold))::integer + END) > 180 THEN 'At Risk' + + -- Very high stock cover is at risk too + WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) > 365 THEN 'At Risk' + + -- New products (less than 30 days old) + WHEN (CASE + WHEN ci.created_at IS NULL AND hd.date_first_sold IS NULL THEN 0 + WHEN ci.created_at IS NULL THEN (_current_date - hd.date_first_sold)::integer + WHEN hd.date_first_sold IS NULL THEN (_current_date - ci.created_at::date)::integer + ELSE (_current_date - LEAST(ci.created_at::date, hd.date_first_sold))::integer + END) <= 30 THEN 'New' + + -- If none of the above, assume Healthy + ELSE 'Healthy' + END + END AS status, + + -- Growth Metrics (P3) - using safe_divide and std_numeric for consistency + std_numeric(safe_divide((sa.sales_30d - ppm.sales_prev_30d) * 100.0, ppm.sales_prev_30d), 2) AS sales_growth_30d_vs_prev, + std_numeric(safe_divide((sa.revenue_30d - ppm.revenue_prev_30d) * 100.0, ppm.revenue_prev_30d), 2) AS revenue_growth_30d_vs_prev, + std_numeric(safe_divide((sa.sales_30d - ppm.sales_30d_last_year) * 100.0, ppm.sales_30d_last_year), 2) AS sales_growth_yoy, + std_numeric(safe_divide((sa.revenue_30d - ppm.revenue_30d_last_year) * 100.0, ppm.revenue_30d_last_year), 2) AS revenue_growth_yoy, + + -- Demand Variability (P3) + std_numeric(dv.sales_variance, 2) AS sales_variance_30d, + std_numeric(dv.sales_std_dev, 2) AS sales_std_dev_30d, + std_numeric(dv.sales_cv, 2) AS sales_cv_30d, + classify_demand_pattern(dv.avg_daily_sales, dv.sales_cv) AS demand_pattern, + + -- Service Levels (P5) + std_numeric(COALESCE(sl.fill_rate_30d, 100), 2) AS fill_rate_30d, + COALESCE(sl.stockout_incidents_30d, 0)::int AS stockout_incidents_30d, + std_numeric(COALESCE(sl.service_level_30d, 100), 2) AS service_level_30d, + COALESCE(sl.lost_sales_incidents_30d, 0)::int AS lost_sales_incidents_30d, + + -- Seasonality (P5) + std_numeric(season.seasonality_index, 2) AS seasonality_index, + COALESCE(season.seasonal_pattern, 'none') AS seasonal_pattern, + season.peak_season + + FROM CurrentInfo ci + LEFT JOIN OnOrderInfo ooi ON ci.pid = ooi.pid + LEFT JOIN HistoricalDates hd ON ci.pid = hd.pid + LEFT JOIN SnapshotAggregates sa ON ci.pid = sa.pid + LEFT JOIN FirstPeriodMetrics fpm ON ci.pid = fpm.pid + LEFT JOIN Settings s ON ci.pid = s.pid + LEFT JOIN LifetimeRevenue lr ON ci.pid = lr.pid + LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid + LEFT JOIN DemandVariability dv ON ci.pid = dv.pid + LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid + LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid + WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked + + ON CONFLICT (pid) DO UPDATE SET + last_calculated = EXCLUDED.last_calculated, + sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable, + barcode = EXCLUDED.barcode, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, vendor_reference = EXCLUDED.vendor_reference, notions_reference = EXCLUDED.notions_reference, line = EXCLUDED.line, subline = EXCLUDED.subline, artist = EXCLUDED.artist, + moq = EXCLUDED.moq, rating = EXCLUDED.rating, reviews = EXCLUDED.reviews, weight = EXCLUDED.weight, length = EXCLUDED.length, width = EXCLUDED.width, height = EXCLUDED.height, country_of_origin = EXCLUDED.country_of_origin, location = EXCLUDED.location, + baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, preorder_count = EXCLUDED.preorder_count, notions_inv_count = EXCLUDED.notions_inv_count, + current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price, + current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross, + on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date, + date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days, + sales_7d = EXCLUDED.sales_7d, revenue_7d = EXCLUDED.revenue_7d, sales_14d = EXCLUDED.sales_14d, revenue_14d = EXCLUDED.revenue_14d, sales_30d = EXCLUDED.sales_30d, revenue_30d = EXCLUDED.revenue_30d, cogs_30d = EXCLUDED.cogs_30d, profit_30d = EXCLUDED.profit_30d, + returns_units_30d = EXCLUDED.returns_units_30d, returns_revenue_30d = EXCLUDED.returns_revenue_30d, discounts_30d = EXCLUDED.discounts_30d, gross_revenue_30d = EXCLUDED.gross_revenue_30d, gross_regular_revenue_30d = EXCLUDED.gross_regular_revenue_30d, + stockout_days_30d = EXCLUDED.stockout_days_30d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, + avg_stock_units_30d = EXCLUDED.avg_stock_units_30d, avg_stock_cost_30d = EXCLUDED.avg_stock_cost_30d, avg_stock_retail_30d = EXCLUDED.avg_stock_retail_30d, avg_stock_gross_30d = EXCLUDED.avg_stock_gross_30d, + received_qty_30d = EXCLUDED.received_qty_30d, received_cost_30d = EXCLUDED.received_cost_30d, + lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, lifetime_revenue_quality = EXCLUDED.lifetime_revenue_quality, + first_7_days_sales = EXCLUDED.first_7_days_sales, first_7_days_revenue = EXCLUDED.first_7_days_revenue, first_30_days_sales = EXCLUDED.first_30_days_sales, first_30_days_revenue = EXCLUDED.first_30_days_revenue, + first_60_days_sales = EXCLUDED.first_60_days_sales, first_60_days_revenue = EXCLUDED.first_60_days_revenue, first_90_days_sales = EXCLUDED.first_90_days_sales, first_90_days_revenue = EXCLUDED.first_90_days_revenue, + asp_30d = EXCLUDED.asp_30d, acp_30d = EXCLUDED.acp_30d, avg_ros_30d = EXCLUDED.avg_ros_30d, avg_sales_per_day_30d = EXCLUDED.avg_sales_per_day_30d, avg_sales_per_month_30d = EXCLUDED.avg_sales_per_month_30d, + margin_30d = EXCLUDED.margin_30d, markup_30d = EXCLUDED.markup_30d, gmroi_30d = EXCLUDED.gmroi_30d, stockturn_30d = EXCLUDED.stockturn_30d, return_rate_30d = EXCLUDED.return_rate_30d, discount_rate_30d = EXCLUDED.discount_rate_30d, + stockout_rate_30d = EXCLUDED.stockout_rate_30d, markdown_30d = EXCLUDED.markdown_30d, markdown_rate_30d = EXCLUDED.markdown_rate_30d, sell_through_30d = EXCLUDED.sell_through_30d, + -- avg_lead_time_days = EXCLUDED.avg_lead_time_days, -- Updated Periodically + -- abc_class = EXCLUDED.abc_class, -- Updated Periodically + sales_velocity_daily = EXCLUDED.sales_velocity_daily, config_lead_time = EXCLUDED.config_lead_time, config_days_of_stock = EXCLUDED.config_days_of_stock, config_safety_stock = EXCLUDED.config_safety_stock, + planning_period_days = EXCLUDED.planning_period_days, lead_time_forecast_units = EXCLUDED.lead_time_forecast_units, days_of_stock_forecast_units = EXCLUDED.days_of_stock_forecast_units, + planning_period_forecast_units = EXCLUDED.planning_period_forecast_units, lead_time_closing_stock = EXCLUDED.lead_time_closing_stock, days_of_stock_closing_stock = EXCLUDED.days_of_stock_closing_stock, + replenishment_needed_raw = EXCLUDED.replenishment_needed_raw, replenishment_units = EXCLUDED.replenishment_units, replenishment_cost = EXCLUDED.replenishment_cost, replenishment_retail = EXCLUDED.replenishment_retail, replenishment_profit = EXCLUDED.replenishment_profit, + to_order_units = EXCLUDED.to_order_units, forecast_lost_sales_units = EXCLUDED.forecast_lost_sales_units, forecast_lost_revenue = EXCLUDED.forecast_lost_revenue, + stock_cover_in_days = EXCLUDED.stock_cover_in_days, po_cover_in_days = EXCLUDED.po_cover_in_days, sells_out_in_days = EXCLUDED.sells_out_in_days, replenish_date = EXCLUDED.replenish_date, + overstocked_units = EXCLUDED.overstocked_units, overstocked_cost = EXCLUDED.overstocked_cost, overstocked_retail = EXCLUDED.overstocked_retail, is_old_stock = EXCLUDED.is_old_stock, + yesterday_sales = EXCLUDED.yesterday_sales, + status = EXCLUDED.status, + sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev, + revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev, + sales_growth_yoy = EXCLUDED.sales_growth_yoy, + revenue_growth_yoy = EXCLUDED.revenue_growth_yoy, + sales_variance_30d = EXCLUDED.sales_variance_30d, + sales_std_dev_30d = EXCLUDED.sales_std_dev_30d, + sales_cv_30d = EXCLUDED.sales_cv_30d, + demand_pattern = EXCLUDED.demand_pattern, + fill_rate_30d = EXCLUDED.fill_rate_30d, + stockout_incidents_30d = EXCLUDED.stockout_incidents_30d, + service_level_30d = EXCLUDED.service_level_30d, + lost_sales_incidents_30d = EXCLUDED.lost_sales_incidents_30d, + seasonality_index = EXCLUDED.seasonality_index, + seasonal_pattern = EXCLUDED.seasonal_pattern, + peak_season = EXCLUDED.peak_season + WHERE -- Only update if at least one key metric has changed + product_metrics.current_stock IS DISTINCT FROM EXCLUDED.current_stock OR + product_metrics.current_price IS DISTINCT FROM EXCLUDED.current_price OR + product_metrics.current_cost_price IS DISTINCT FROM EXCLUDED.current_cost_price OR + product_metrics.on_order_qty IS DISTINCT FROM EXCLUDED.on_order_qty OR + product_metrics.sales_7d IS DISTINCT FROM EXCLUDED.sales_7d OR + product_metrics.sales_30d IS DISTINCT FROM EXCLUDED.sales_30d OR + product_metrics.revenue_30d IS DISTINCT FROM EXCLUDED.revenue_30d OR + product_metrics.status IS DISTINCT FROM EXCLUDED.status OR + product_metrics.replenishment_units IS DISTINCT FROM EXCLUDED.replenishment_units OR + product_metrics.stock_cover_in_days IS DISTINCT FROM EXCLUDED.stock_cover_in_days OR + product_metrics.yesterday_sales IS DISTINCT FROM EXCLUDED.yesterday_sales OR + -- Check a few other important fields that might change + product_metrics.date_last_sold IS DISTINCT FROM EXCLUDED.date_last_sold OR + product_metrics.earliest_expected_date IS DISTINCT FROM EXCLUDED.earliest_expected_date OR + product_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales OR + product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality + ; + + -- Update the status table with the timestamp from the START of this run + UPDATE public.calculate_status + SET last_calculation_timestamp = _start_time + WHERE module_name = _module_name; + + RAISE NOTICE 'Finished % module. Duration: %', _module_name, clock_timestamp() - _start_time; + +END $$; + +-- Return metrics about the update operation +WITH update_stats AS ( + SELECT + COUNT(*) as total_products, + COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed, + COUNT(*) FILTER (WHERE status = 'Critical') as critical_count, + COUNT(*) FILTER (WHERE status = 'Reorder Soon') as reorder_soon_count, + COUNT(*) FILTER (WHERE status = 'Healthy') as healthy_count, + COUNT(*) FILTER (WHERE status = 'Overstock') as overstock_count, + COUNT(*) FILTER (WHERE status = 'At Risk') as at_risk_count, + COUNT(*) FILTER (WHERE status = 'New') as new_count + FROM public.product_metrics +) +SELECT + rows_processed, + total_products, + critical_count, + reorder_soon_count, + healthy_count, + overstock_count, + at_risk_count, + new_count, + ROUND((rows_processed::numeric / NULLIF(total_products, 0)) * 100, 2) as update_percentage +FROM update_stats; \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/utils/db.js b/inventory-server/scripts/metrics-new/utils/db.js new file mode 100644 index 0000000..6d4abef --- /dev/null +++ b/inventory-server/scripts/metrics-new/utils/db.js @@ -0,0 +1,39 @@ +const { Pool } = require('pg'); +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '../../..', '.env') }); + +// Database configuration +const dbConfig = { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432, + ssl: process.env.DB_SSL === 'true', + // Add performance optimizations + max: 10, // connection pool max size + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 60000 +}; + +// Create a single pool instance to be reused +const pool = new Pool(dbConfig); + +// Add event handlers for pool +pool.on('error', (err, client) => { + console.error('Unexpected error on idle client', err); +}); + +async function getConnection() { + return await pool.connect(); +} + +async function closePool() { + await pool.end(); +} + +module.exports = { + dbConfig, + getConnection, + closePool +}; \ No newline at end of file diff --git a/inventory-server/scripts/metrics-new/utils/progress.js b/inventory-server/scripts/metrics-new/utils/progress.js new file mode 100644 index 0000000..b1ca9b1 --- /dev/null +++ b/inventory-server/scripts/metrics-new/utils/progress.js @@ -0,0 +1,183 @@ +const fs = require('fs'); +const path = require('path'); + +// Helper function to format elapsed time +function formatElapsedTime(startTime) { + let elapsed; + + // If startTime is a timestamp (number representing milliseconds since epoch) + if (typeof startTime === 'number') { + // Check if it's a timestamp (will be a large number like 1700000000000) + if (startTime > 1000000000) { // timestamps are in milliseconds since 1970 + elapsed = Date.now() - startTime; + } else { + // Assume it's already elapsed milliseconds + elapsed = startTime; + } + } else if (startTime instanceof Date) { + elapsed = Date.now() - startTime.getTime(); + } else { + // Default to 0 if invalid input + elapsed = 0; + } + + const seconds = Math.floor(elapsed / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } +} + +// Helper function to estimate remaining time +function estimateRemaining(startTime, current, total) { + // Handle edge cases + if (!current || current === 0 || !total || total === 0 || current >= total) { + return null; + } + + // Calculate elapsed time in milliseconds + const elapsed = Date.now() - startTime; + if (elapsed <= 0) return null; + + // Calculate rate (items per millisecond) + const rate = current / elapsed; + if (rate <= 0) return null; + + // Calculate remaining time in milliseconds + const remaining = (total - current) / rate; + + // Convert to readable format + const seconds = Math.floor(remaining / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } +} + +// Helper function to calculate rate +function calculateRate(startTime, current) { + const elapsed = (Date.now() - startTime) / 1000; // Convert to seconds + return elapsed > 0 ? Math.round(current / elapsed) : 0; +} + +// Set up logging +const LOG_DIR = path.join(__dirname, '../../../logs'); +const ERROR_LOG = path.join(LOG_DIR, 'import-errors.log'); +const IMPORT_LOG = path.join(LOG_DIR, 'import.log'); +const STATUS_FILE = path.join(LOG_DIR, 'metrics-status.json'); + +// Ensure log directory exists +if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); +} + +// Helper function to log errors +function logError(error, context = '') { + const timestamp = new Date().toISOString(); + const errorMessage = `[${timestamp}] ${context}\nError: ${error.message}\nStack: ${error.stack}\n\n`; + + // Log to error file + fs.appendFileSync(ERROR_LOG, errorMessage); + + // Also log to console + console.error(`\n${context}\nError: ${error.message}`); +} + +// Helper function to log import progress +function logImport(message) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${message}\n`; + fs.appendFileSync(IMPORT_LOG, logMessage); +} + +// Helper function to output progress +function outputProgress(data) { + // Save progress to file for resumption + saveProgress(data); + // Format as SSE event + const event = { + progress: data + }; + // Always send to stdout for frontend + process.stdout.write(JSON.stringify(event) + '\n'); + + // Log significant events to disk + const isSignificant = + // Operation starts + (data.operation && !data.current) || + // Operation completions and errors + data.status === 'complete' || + data.status === 'error' || + // Major phase changes + data.operation?.includes('Starting ABC classification') || + data.operation?.includes('Starting time-based aggregates') || + data.operation?.includes('Starting vendor metrics'); + + if (isSignificant) { + logImport(`${data.operation || 'Operation'}${data.message ? ': ' + data.message : ''}${data.error ? ' Error: ' + data.error : ''}${data.status ? ' Status: ' + data.status : ''}`); + } +} + +function saveProgress(progress) { + try { + fs.writeFileSync(STATUS_FILE, JSON.stringify({ + ...progress, + timestamp: Date.now() + })); + } catch (err) { + console.error('Failed to save progress:', err); + } +} + +function clearProgress() { + try { + if (fs.existsSync(STATUS_FILE)) { + fs.unlinkSync(STATUS_FILE); + } + } catch (err) { + console.error('Failed to clear progress:', err); + } +} + +function getProgress() { + try { + if (fs.existsSync(STATUS_FILE)) { + const progress = JSON.parse(fs.readFileSync(STATUS_FILE, 'utf8')); + // Check if the progress is still valid (less than 1 hour old) + if (progress.timestamp && Date.now() - progress.timestamp < 3600000) { + return progress; + } else { + // Clear old progress + clearProgress(); + } + } + } catch (err) { + console.error('Failed to read progress:', err); + clearProgress(); + } + return null; +} + +module.exports = { + formatElapsedTime, + estimateRemaining, + calculateRate, + logError, + logImport, + outputProgress, + saveProgress, + clearProgress, + getProgress +}; \ No newline at end of file diff --git a/inventory-server/scripts/reset-db.js b/inventory-server/scripts/reset-db.js new file mode 100644 index 0000000..52d50d6 --- /dev/null +++ b/inventory-server/scripts/reset-db.js @@ -0,0 +1,599 @@ +const { Client } = require('pg'); +const path = require('path'); +const dotenv = require('dotenv'); +const fs = require('fs'); + +dotenv.config({ path: path.join(__dirname, '../.env') }); + +const dbConfig = { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432 +}; + +// Tables to always protect from being dropped +const PROTECTED_TABLES = [ + 'users', + 'permissions', + 'user_permissions', + 'calculate_history', + 'import_history', + 'ai_prompts', + 'ai_validation_performance', + 'templates', + 'reusable_images', + 'imported_daily_inventory', + 'imported_product_stat_history', + 'imported_product_current_prices' +]; + +// Helper function to output progress in JSON format +function outputProgress(data) { + if (!data.status) { + data = { + status: 'running', + ...data + }; + } + console.log(JSON.stringify(data)); +} + +// Core tables that must be created +const CORE_TABLES = [ + 'products', + 'orders', + 'purchase_orders', + 'categories', + 'product_categories' +]; + +// Split SQL into individual statements +function splitSQLStatements(sql) { + // First, normalize line endings + sql = sql.replace(/\r\n/g, '\n'); + + // Track statement boundaries + let statements = []; + let currentStatement = ''; + let inString = false; + let stringChar = ''; + let inDollarQuote = false; + let dollarQuoteTag = ''; + + // Process character by character + for (let i = 0; i < sql.length; i++) { + const char = sql[i]; + const nextChar = sql[i + 1] || ''; + + // Handle dollar quotes + if (char === '$' && !inString) { + // Look ahead to find the dollar quote tag + let tag = '$'; + let j = i + 1; + while (j < sql.length && sql[j] !== '$') { + tag += sql[j]; + j++; + } + tag += '$'; + + if (j < sql.length) { // Found closing $ + if (!inDollarQuote) { + inDollarQuote = true; + dollarQuoteTag = tag; + currentStatement += tag; + i = j; + continue; + } else if (sql.substring(i, j + 1) === dollarQuoteTag) { + inDollarQuote = false; + dollarQuoteTag = ''; + currentStatement += tag; + i = j; + continue; + } + } + } + + // Handle string literals (only if not in dollar quote) + if (!inDollarQuote && (char === "'" || char === '"') && sql[i - 1] !== '\\') { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + } + } + + // Handle comments (only if not in string or dollar quote) + if (!inString && !inDollarQuote) { + if (char === '-' && nextChar === '-') { + // Skip to end of line + while (i < sql.length && sql[i] !== '\n') i++; + continue; + } + + if (char === '/' && nextChar === '*') { + // Skip until closing */ + i += 2; + while (i < sql.length && (sql[i] !== '*' || sql[i + 1] !== '/')) i++; + i++; // Skip the closing / + continue; + } + } + + // Handle statement boundaries (only if not in string or dollar quote) + if (!inString && !inDollarQuote && char === ';') { + if (currentStatement.trim()) { + statements.push(currentStatement.trim()); + } + currentStatement = ''; + } else { + currentStatement += char; + } + } + + // Add the last statement if it exists + if (currentStatement.trim()) { + statements.push(currentStatement.trim()); + } + + return statements; +} + +async function resetDatabase() { + outputProgress({ + operation: 'Starting database reset', + message: 'Connecting to database...' + }); + + // Debug: Log current directory and file paths + outputProgress({ + operation: 'Debug paths', + message: { + currentDir: process.cwd(), + __dirname: __dirname, + schemaPath: path.join(__dirname, '../db/schema.sql') + } + }); + + const client = new Client(dbConfig); + await client.connect(); + + try { + // Check PostgreSQL version and user + outputProgress({ + operation: 'Checking database', + message: 'Verifying PostgreSQL version and user privileges...' + }); + + const versionResult = await client.query('SELECT version()'); + const userResult = await client.query('SELECT current_user, current_database()'); + + outputProgress({ + operation: 'Database info', + message: { + version: versionResult.rows[0].version, + user: userResult.rows[0].current_user, + database: userResult.rows[0].current_database + } + }); + + // Get list of all tables in the current database + outputProgress({ + operation: 'Getting table list', + message: 'Retrieving all table names...' + }); + + const tablesResult = await client.query(` + SELECT string_agg(tablename, ', ') as tables + FROM pg_tables + WHERE schemaname = 'public' + AND tablename NOT IN (SELECT unnest($1::text[])); + `, [PROTECTED_TABLES]); + + if (!tablesResult.rows[0].tables) { + outputProgress({ + operation: 'No tables found', + message: 'Database is already empty' + }); + } else { + outputProgress({ + operation: 'Dropping tables', + message: 'Dropping all existing tables...' + }); + + // Disable triggers/foreign key checks + await client.query('SET session_replication_role = \'replica\';'); + + // Drop all tables except users + const tables = tablesResult.rows[0].tables.split(', '); + for (const table of tables) { + if (!PROTECTED_TABLES.includes(table)) { + await client.query(`DROP TABLE IF EXISTS "${table}" CASCADE`); + } + } + + // Only drop types if we're not preserving history tables + const historyTablesExist = await client.query(` + SELECT EXISTS ( + SELECT FROM pg_tables + WHERE schemaname = 'public' + AND tablename IN ('calculate_history', 'import_history') + ); + `); + + if (!historyTablesExist.rows[0].exists) { + await client.query('DROP TYPE IF EXISTS calculation_status CASCADE;'); + await client.query('DROP TYPE IF EXISTS module_name CASCADE;'); + } + + // Re-enable triggers/foreign key checks + await client.query('SET session_replication_role = \'origin\';'); + } + + // Create enum types if they don't exist + outputProgress({ + operation: 'Creating enum types', + message: 'Setting up required enum types...' + }); + + // Check if types exist before creating + const typesExist = await client.query(` + SELECT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'calculation_status' + ) as calc_status_exists, + EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'module_name' + ) as module_name_exists; + `); + + if (!typesExist.rows[0].calc_status_exists) { + await client.query(`CREATE TYPE calculation_status AS ENUM ('running', 'completed', 'failed', 'cancelled')`); + } + + if (!typesExist.rows[0].module_name_exists) { + await client.query(` + CREATE TYPE module_name AS ENUM ( + 'product_metrics', + 'time_aggregates', + 'financial_metrics', + 'vendor_metrics', + 'category_metrics', + 'brand_metrics', + 'sales_forecasts', + 'abc_classification', + 'daily_snapshots', + 'periodic_metrics' + ) + `); + } + + // Read and execute main schema first (core tables) + outputProgress({ + operation: 'Running database setup', + message: 'Creating core tables...' + }); + const schemaPath = path.join(__dirname, '../db/schema.sql'); + + // Verify file exists + if (!fs.existsSync(schemaPath)) { + throw new Error(`Schema file not found at: ${schemaPath}`); + } + + const schemaSQL = fs.readFileSync(schemaPath, 'utf8'); + + outputProgress({ + operation: 'Schema file', + message: { + path: schemaPath, + exists: fs.existsSync(schemaPath), + size: fs.statSync(schemaPath).size, + firstFewLines: schemaSQL.split('\n').slice(0, 5).join('\n') + } + }); + + // Execute schema statements one at a time + const statements = splitSQLStatements(schemaSQL); + outputProgress({ + operation: 'SQL Execution', + message: { + totalStatements: statements.length, + statements: statements.map((stmt, i) => ({ + number: i + 1, + preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : '') + })) + } + }); + + // Start a transaction for better error handling + await client.query('BEGIN'); + try { + for (let i = 0; i < statements.length; i++) { + const stmt = statements[i]; + try { + const result = await client.query(stmt); + + // Verify if table was created (if this was a CREATE TABLE statement) + if (stmt.trim().toLowerCase().startsWith('create table')) { + const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?["]?(\w+)["]?/i)?.[1]; + if (tableName) { + const tableExists = await client.query(` + SELECT COUNT(*) as count + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + `, [tableName]); + + outputProgress({ + operation: 'Table Creation Verification', + message: { + table: tableName, + exists: tableExists.rows[0].count > 0 + } + }); + } + } + + outputProgress({ + operation: 'SQL Progress', + message: { + statement: i + 1, + total: statements.length, + preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''), + rowCount: result.rowCount + } + }); + + // Commit in chunks of 10 statements to avoid long-running transactions + if (i > 0 && i % 10 === 0) { + await client.query('COMMIT'); + await client.query('BEGIN'); + } + } catch (sqlError) { + await client.query('ROLLBACK'); + outputProgress({ + status: 'error', + operation: 'SQL Error', + error: sqlError.message, + statement: stmt, + statementNumber: i + 1 + }); + throw sqlError; + } + } + // Commit the final transaction + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + + // Verify core tables were created + const existingTables = (await client.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + `)).rows.map(t => t.table_name); + + outputProgress({ + operation: 'Core tables verification', + message: { + found: existingTables, + expected: CORE_TABLES + } + }); + + const missingCoreTables = CORE_TABLES.filter( + t => !existingTables.includes(t) + ); + + if (missingCoreTables.length > 0) { + throw new Error( + `Failed to create core tables: ${missingCoreTables.join(', ')}` + ); + } + + outputProgress({ + operation: 'Core tables created', + message: `Successfully created tables: ${CORE_TABLES.join(', ')}` + }); + + // Now read and execute config schema (since core tables exist) + outputProgress({ + operation: 'Running config setup', + message: 'Creating configuration tables...' + }); + const configSchemaPath = path.join(__dirname, '../db/config-schema-new.sql'); + + // Verify file exists + if (!fs.existsSync(configSchemaPath)) { + throw new Error(`Config schema file not found at: ${configSchemaPath}`); + } + + const configSchemaSQL = fs.readFileSync(configSchemaPath, 'utf8'); + + outputProgress({ + operation: 'Config Schema file', + message: { + path: configSchemaPath, + exists: fs.existsSync(configSchemaPath), + size: fs.statSync(configSchemaPath).size, + firstFewLines: configSchemaSQL.split('\n').slice(0, 5).join('\n') + } + }); + + // Execute config schema statements one at a time + const configStatements = splitSQLStatements(configSchemaSQL); + outputProgress({ + operation: 'Config SQL Execution', + message: { + totalStatements: configStatements.length, + statements: configStatements.map((stmt, i) => ({ + number: i + 1, + preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : '') + })) + } + }); + + // Start a transaction for better error handling + await client.query('BEGIN'); + try { + for (let i = 0; i < configStatements.length; i++) { + const stmt = configStatements[i]; + try { + const result = await client.query(stmt); + + outputProgress({ + operation: 'Config SQL Progress', + message: { + statement: i + 1, + total: configStatements.length, + preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''), + rowCount: result.rowCount + } + }); + + // Commit in chunks of 10 statements to avoid long-running transactions + if (i > 0 && i % 10 === 0) { + await client.query('COMMIT'); + await client.query('BEGIN'); + } + } catch (sqlError) { + await client.query('ROLLBACK'); + outputProgress({ + status: 'error', + operation: 'Config SQL Error', + error: sqlError.message, + statement: stmt, + statementNumber: i + 1 + }); + throw sqlError; + } + } + // Commit the final transaction + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + + // Read and execute metrics schema (metrics tables) + outputProgress({ + operation: 'Running metrics setup', + message: 'Creating metrics tables...' + }); + const metricsSchemaPath = path.join(__dirname, '../db/metrics-schema-new.sql'); + + // Verify file exists + if (!fs.existsSync(metricsSchemaPath)) { + throw new Error(`Metrics schema file not found at: ${metricsSchemaPath}`); + } + + const metricsSchemaSQL = fs.readFileSync(metricsSchemaPath, 'utf8'); + + outputProgress({ + operation: 'Metrics Schema file', + message: { + path: metricsSchemaPath, + exists: fs.existsSync(metricsSchemaPath), + size: fs.statSync(metricsSchemaPath).size, + firstFewLines: metricsSchemaSQL.split('\n').slice(0, 5).join('\n') + } + }); + + // Execute metrics schema statements one at a time + const metricsStatements = splitSQLStatements(metricsSchemaSQL); + outputProgress({ + operation: 'Metrics SQL Execution', + message: { + totalStatements: metricsStatements.length, + statements: metricsStatements.map((stmt, i) => ({ + number: i + 1, + preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : '') + })) + } + }); + + // Start a transaction for better error handling + await client.query('BEGIN'); + try { + for (let i = 0; i < metricsStatements.length; i++) { + const stmt = metricsStatements[i]; + try { + const result = await client.query(stmt); + + outputProgress({ + operation: 'Metrics SQL Progress', + message: { + statement: i + 1, + total: metricsStatements.length, + preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''), + rowCount: result.rowCount + } + }); + + // Commit in chunks of 10 statements to avoid long-running transactions + if (i > 0 && i % 10 === 0) { + await client.query('COMMIT'); + await client.query('BEGIN'); + } + } catch (sqlError) { + await client.query('ROLLBACK'); + outputProgress({ + status: 'error', + operation: 'Metrics SQL Error', + error: sqlError.message, + statement: stmt, + statementNumber: i + 1 + }); + throw sqlError; + } + } + // Commit the final transaction + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + + outputProgress({ + status: 'complete', + operation: 'Database reset complete', + message: 'Database has been reset and all tables recreated' + }); + } catch (error) { + outputProgress({ + status: 'error', + operation: 'Failed to reset database', + error: error.message, + stack: error.stack + }); + process.exit(1); + } finally { + // Make sure to re-enable foreign key checks if they were disabled + try { + await client.query('SET session_replication_role = \'origin\''); + } catch (e) { + console.error('Error re-enabling foreign key checks:', e.message); + } + + // Close the database connection + await client.end(); + } +} + +// Export if required as a module +if (typeof module !== 'undefined' && module.exports) { + module.exports = resetDatabase; +} + +// Run if called directly +if (require.main === module) { + resetDatabase().catch(error => { + console.error('Error:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/inventory-server/scripts/reset-metrics-new.js b/inventory-server/scripts/reset-metrics-new.js new file mode 100644 index 0000000..2e627e0 --- /dev/null +++ b/inventory-server/scripts/reset-metrics-new.js @@ -0,0 +1,384 @@ +const { Client } = require('pg'); +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const dbConfig = { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432 +}; + +function outputProgress(data) { + if (!data.status) { + data = { + status: 'running', + ...data + }; + } + console.log(JSON.stringify(data)); +} + +// Tables to always protect from being dropped +const PROTECTED_TABLES = [ + 'users', + 'permissions', + 'user_permissions', + 'calculate_history', + 'import_history', + 'ai_prompts', + 'ai_validation_performance', + 'templates', + 'reusable_images', + 'imported_daily_inventory', + 'imported_product_stat_history', + 'imported_product_current_prices' +]; + +// Split SQL into individual statements +function splitSQLStatements(sql) { + sql = sql.replace(/\r\n/g, '\n'); + let statements = []; + let currentStatement = ''; + let inString = false; + let stringChar = ''; + + for (let i = 0; i < sql.length; i++) { + const char = sql[i]; + const nextChar = sql[i + 1] || ''; + + if ((char === "'" || char === '"') && sql[i - 1] !== '\\') { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + } + } + + if (!inString && char === '-' && nextChar === '-') { + while (i < sql.length && sql[i] !== '\n') i++; + continue; + } + + if (!inString && char === '/' && nextChar === '*') { + i += 2; + while (i < sql.length && (sql[i] !== '*' || sql[i + 1] !== '/')) i++; + i++; + continue; + } + + if (!inString && char === ';') { + if (currentStatement.trim()) { + statements.push(currentStatement.trim()); + } + currentStatement = ''; + } else { + currentStatement += char; + } + } + + if (currentStatement.trim()) { + statements.push(currentStatement.trim()); + } + + return statements; +} + +async function resetMetrics() { + let client; + try { + outputProgress({ + operation: 'Starting metrics reset', + message: 'Connecting to database...' + }); + + client = new Client(dbConfig); + await client.connect(); + + // Get metrics tables from the schema file by looking for CREATE TABLE statements + const schemaPath = path.resolve(__dirname, '../db/metrics-schema-new.sql'); + if (!fs.existsSync(schemaPath)) { + throw new Error(`Schema file not found at: ${schemaPath}`); + } + + const schemaSQL = fs.readFileSync(schemaPath, 'utf8'); + const createTableRegex = /create\s+table\s+(?:if\s+not\s+exists\s+)?["]?(?:public\.)?(\w+)["]?/gi; + let metricsTables = []; + let match; + + while ((match = createTableRegex.exec(schemaSQL)) !== null) { + if (match[1] && !PROTECTED_TABLES.includes(match[1])) { + metricsTables.push(match[1]); + } + } + + if (metricsTables.length === 0) { + throw new Error('No tables found in the schema file'); + } + + outputProgress({ + operation: 'Schema analysis', + message: `Found ${metricsTables.length} metrics tables in schema: ${metricsTables.join(', ')}` + }); + + // Explicitly begin a transaction + await client.query('BEGIN'); + + // First verify current state + const initialTables = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = ANY($1) + AND tablename NOT IN (SELECT unnest($2::text[])) + `, [metricsTables, PROTECTED_TABLES]); + + outputProgress({ + operation: 'Initial state', + message: `Found ${initialTables.rows.length} existing metrics tables: ${initialTables.rows.map(t => t.name).join(', ')}` + }); + + // Disable foreign key checks at the start + await client.query('SET session_replication_role = \'replica\''); + + // Drop all metrics tables in reverse order to handle dependencies + outputProgress({ + operation: 'Dropping metrics tables', + message: 'Removing existing metrics tables...' + }); + + // Reverse the array to handle dependencies properly + for (const table of [...metricsTables].reverse()) { + // Skip protected tables (redundant check) + if (PROTECTED_TABLES.includes(table)) { + outputProgress({ + operation: 'Protected table', + message: `Skipping protected table: ${table}` + }); + continue; + } + + try { + // Use NOWAIT to avoid hanging if there's a lock + await client.query(`DROP TABLE IF EXISTS "${table}" CASCADE`); + + // Verify the table was actually dropped + const checkDrop = await client.query(` + SELECT COUNT(*) as count + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = $1 + `, [table]); + + if (parseInt(checkDrop.rows[0].count) > 0) { + throw new Error(`Failed to drop table ${table} - table still exists`); + } + + outputProgress({ + operation: 'Table dropped', + message: `Successfully dropped table: ${table}` + }); + + // Commit after each table drop to ensure locks are released + await client.query('COMMIT'); + // Start a new transaction for the next table + await client.query('BEGIN'); + // Re-disable foreign key constraints for the new transaction + await client.query('SET session_replication_role = \'replica\''); + } catch (err) { + outputProgress({ + status: 'error', + operation: 'Drop table error', + message: `Error dropping table ${table}: ${err.message}` + }); + await client.query('ROLLBACK'); + // Re-start transaction for next table + await client.query('BEGIN'); + await client.query('SET session_replication_role = \'replica\''); + } + } + + // Verify all tables were dropped + const afterDrop = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = ANY($1) + `, [metricsTables]); + + if (afterDrop.rows.length > 0) { + throw new Error(`Failed to drop all tables. Remaining tables: ${afterDrop.rows.map(t => t.name).join(', ')}`); + } + + // Make sure we have a fresh transaction here + await client.query('COMMIT'); + await client.query('BEGIN'); + await client.query('SET session_replication_role = \'replica\''); + + // Read metrics schema + outputProgress({ + operation: 'Reading schema', + message: 'Loading metrics schema file...' + }); + + const statements = splitSQLStatements(schemaSQL); + + outputProgress({ + operation: 'Schema loaded', + message: `Found ${statements.length} SQL statements to execute` + }); + + // Execute schema statements + for (let i = 0; i < statements.length; i++) { + const stmt = statements[i]; + try { + const result = await client.query(stmt); + + // If this is a CREATE TABLE statement, verify the table was created + if (stmt.trim().toLowerCase().startsWith('create table')) { + const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?["]?(?:public\.)?(\w+)["]?/i)?.[1]; + if (tableName) { + const checkCreate = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = $1 + `, [tableName]); + + if (checkCreate.rows.length === 0) { + throw new Error(`Failed to create table ${tableName} - table does not exist after CREATE statement`); + } + + outputProgress({ + operation: 'Table created', + message: `Successfully created table: ${tableName}` + }); + } + } + + outputProgress({ + operation: 'SQL Progress', + message: { + statement: i + 1, + total: statements.length, + preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''), + rowCount: result.rowCount + } + }); + + // Commit every 10 statements to avoid long-running transactions + if (i > 0 && i % 10 === 0) { + await client.query('COMMIT'); + await client.query('BEGIN'); + await client.query('SET session_replication_role = \'replica\''); + } + } catch (sqlError) { + outputProgress({ + status: 'error', + operation: 'SQL Error', + message: { + error: sqlError.message, + statement: stmt, + statementNumber: i + 1 + } + }); + await client.query('ROLLBACK'); + throw sqlError; + } + } + + // Final commit for any pending statements + await client.query('COMMIT'); + + // Start new transaction for final checks + await client.query('BEGIN'); + + // Re-enable foreign key checks after all tables are created + await client.query('SET session_replication_role = \'origin\''); + + // Verify metrics tables were created + outputProgress({ + operation: 'Verifying metrics tables', + message: 'Checking all metrics tables were created...' + }); + + const metricsTablesResult = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = ANY($1) + `, [metricsTables]); + + outputProgress({ + operation: 'Tables found', + message: `Found ${metricsTablesResult.rows.length} tables: ${metricsTablesResult.rows.map(t => t.name).join(', ')}` + }); + + const existingMetricsTables = metricsTablesResult.rows.map(t => t.name); + const missingMetricsTables = metricsTables.filter(t => !existingMetricsTables.includes(t)); + + if (missingMetricsTables.length > 0) { + // Do one final check of the actual tables + const finalCheck = await client.query(` + SELECT tablename as name + FROM pg_tables + WHERE schemaname = 'public' + `); + outputProgress({ + operation: 'Final table check', + message: `All database tables: ${finalCheck.rows.map(t => t.name).join(', ')}` + }); + await client.query('ROLLBACK'); + throw new Error(`Failed to create metrics tables: ${missingMetricsTables.join(', ')}`); + } + + // Commit final transaction + await client.query('COMMIT'); + + outputProgress({ + status: 'complete', + operation: 'Reset complete', + message: 'All metrics tables have been reset successfully' + }); + } catch (error) { + outputProgress({ + status: 'error', + operation: 'Reset failed', + message: error.message, + stack: error.stack + }); + + if (client) { + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + console.error('Error during rollback:', rollbackError); + } + // Make sure to re-enable foreign key checks even if there's an error + await client.query('SET session_replication_role = \'origin\'').catch(() => {}); + } + throw error; + } finally { + if (client) { + // One final attempt to ensure foreign key checks are enabled + await client.query('SET session_replication_role = \'origin\'').catch(() => {}); + await client.end(); + } + } +} + +// Export if required as a module +if (typeof module !== 'undefined' && module.exports) { + module.exports = resetMetrics; +} + +// Run if called from command line +if (require.main === module) { + resetMetrics().catch(error => { + console.error('Error:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/inventory-server/src/middleware/cors.js b/inventory-server/src/middleware/cors.js new file mode 100644 index 0000000..7c92e03 --- /dev/null +++ b/inventory-server/src/middleware/cors.js @@ -0,0 +1,40 @@ +const cors = require('cors'); + +// Single CORS middleware for all endpoints +const corsMiddleware = cors({ + origin: [ + 'https://inventory.kent.pw', + 'http://localhost:5175', + 'https://acot.site', + /^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/, + /^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/ + ], + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + exposedHeaders: ['Content-Type'], + credentials: true +}); + +// Error handler for CORS +const corsErrorHandler = (err, req, res, next) => { + if (err.message === 'CORS not allowed') { + console.error('CORS Error:', { + origin: req.get('Origin'), + method: req.method, + path: req.path, + headers: req.headers + }); + res.status(403).json({ + error: 'CORS not allowed', + origin: req.get('Origin'), + message: 'Origin not in allowed list: https://inventory.kent.pw, https://acot.site, localhost:5175, 192.168.x.x, or 10.x.x.x' + }); + } else { + next(err); + } +}; + +module.exports = { + corsMiddleware, + corsErrorHandler +}; \ No newline at end of file diff --git a/inventory-server/src/routes/ai-prompts.js b/inventory-server/src/routes/ai-prompts.js new file mode 100644 index 0000000..98136ca --- /dev/null +++ b/inventory-server/src/routes/ai-prompts.js @@ -0,0 +1,319 @@ +const express = require('express'); +const router = express.Router(); + +// Get all AI prompts +router.get('/', async (req, res) => { + try { + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM ai_prompts + ORDER BY prompt_type ASC, company ASC + `); + res.json(result.rows); + } catch (error) { + console.error('Error fetching AI prompts:', error); + res.status(500).json({ + error: 'Failed to fetch AI prompts', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get prompt by ID +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM ai_prompts + WHERE id = $1 + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'AI prompt not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching AI prompt:', error); + res.status(500).json({ + error: 'Failed to fetch AI prompt', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get prompt by type (general, system, company_specific) +router.get('/by-type', async (req, res) => { + try { + const { type, company } = req.query; + const pool = req.app.locals.pool; + + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Validate prompt type + if (!type || !['general', 'system', 'company_specific'].includes(type)) { + return res.status(400).json({ + error: 'Valid type query parameter is required (general, system, or company_specific)' + }); + } + + // For company_specific type, company ID is required + if (type === 'company_specific' && !company) { + return res.status(400).json({ + error: 'Company ID is required for company_specific prompt type' + }); + } + + // For general and system types, company should not be provided + if ((type === 'general' || type === 'system') && company) { + return res.status(400).json({ + error: 'Company ID should not be provided for general or system prompt types' + }); + } + + // Build the query based on the type + let query, params; + if (type === 'company_specific') { + query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1 AND company = $2'; + params = [type, company]; + } else { + query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1'; + params = [type]; + } + + // Execute the query + const result = await pool.query(query, params); + + // Check if any prompt was found + if (result.rows.length === 0) { + let errorMessage; + if (type === 'company_specific') { + errorMessage = `AI prompt not found for company ${company}`; + } else { + errorMessage = `${type.charAt(0).toUpperCase() + type.slice(1)} AI prompt not found`; + } + return res.status(404).json({ error: errorMessage }); + } + + // Return the first matching prompt + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching AI prompt by type:', error); + res.status(500).json({ + error: 'Failed to fetch AI prompt', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Create new AI prompt +router.post('/', async (req, res) => { + try { + const { + prompt_text, + prompt_type, + company + } = req.body; + + // Validate required fields + if (!prompt_text || !prompt_type) { + return res.status(400).json({ error: 'Prompt text and type are required' }); + } + + // Validate prompt type + if (!['general', 'company_specific', 'system'].includes(prompt_type)) { + return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' }); + } + + // Validate company is provided for company-specific prompts + if (prompt_type === 'company_specific' && !company) { + return res.status(400).json({ error: 'Company is required for company-specific prompts' }); + } + + // Validate company is not provided for general or system prompts + if ((prompt_type === 'general' || prompt_type === 'system') && company) { + return res.status(400).json({ error: 'Company should not be provided for general or system prompts' }); + } + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + INSERT INTO ai_prompts ( + prompt_text, + prompt_type, + company + ) VALUES ($1, $2, $3) + RETURNING * + `, [ + prompt_text, + prompt_type, + company + ]); + + res.status(201).json(result.rows[0]); + } catch (error) { + console.error('Error creating AI prompt:', error); + + // Check for unique constraint violations + if (error instanceof Error && error.message.includes('unique constraint')) { + if (error.message.includes('unique_company_prompt')) { + return res.status(409).json({ + error: 'A prompt already exists for this company', + details: error.message + }); + } else if (error.message.includes('idx_unique_general_prompt')) { + return res.status(409).json({ + error: 'A general prompt already exists', + details: error.message + }); + } else if (error.message.includes('idx_unique_system_prompt')) { + return res.status(409).json({ + error: 'A system prompt already exists', + details: error.message + }); + } + } + + res.status(500).json({ + error: 'Failed to create AI prompt', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Update AI prompt +router.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const { + prompt_text, + prompt_type, + company + } = req.body; + + // Validate required fields + if (!prompt_text || !prompt_type) { + return res.status(400).json({ error: 'Prompt text and type are required' }); + } + + // Validate prompt type + if (!['general', 'company_specific', 'system'].includes(prompt_type)) { + return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' }); + } + + // Validate company is provided for company-specific prompts + if (prompt_type === 'company_specific' && !company) { + return res.status(400).json({ error: 'Company is required for company-specific prompts' }); + } + + // Validate company is not provided for general or system prompts + if ((prompt_type === 'general' || prompt_type === 'system') && company) { + return res.status(400).json({ error: 'Company should not be provided for general or system prompts' }); + } + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Check if the prompt exists + const checkResult = await pool.query('SELECT * FROM ai_prompts WHERE id = $1', [id]); + if (checkResult.rows.length === 0) { + return res.status(404).json({ error: 'AI prompt not found' }); + } + + const result = await pool.query(` + UPDATE ai_prompts + SET + prompt_text = $1, + prompt_type = $2, + company = $3 + WHERE id = $4 + RETURNING * + `, [ + prompt_text, + prompt_type, + company, + id + ]); + + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating AI prompt:', error); + + // Check for unique constraint violations + if (error instanceof Error && error.message.includes('unique constraint')) { + if (error.message.includes('unique_company_prompt')) { + return res.status(409).json({ + error: 'A prompt already exists for this company', + details: error.message + }); + } else if (error.message.includes('idx_unique_general_prompt')) { + return res.status(409).json({ + error: 'A general prompt already exists', + details: error.message + }); + } else if (error.message.includes('idx_unique_system_prompt')) { + return res.status(409).json({ + error: 'A system prompt already exists', + details: error.message + }); + } + } + + res.status(500).json({ + error: 'Failed to update AI prompt', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Delete AI prompt +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query('DELETE FROM ai_prompts WHERE id = $1 RETURNING *', [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'AI prompt not found' }); + } + + res.json({ message: 'AI prompt deleted successfully' }); + } catch (error) { + console.error('Error deleting AI prompt:', error); + res.status(500).json({ + error: 'Failed to delete AI prompt', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Error handling middleware +router.use((err, req, res, next) => { + console.error('AI prompts route error:', err); + res.status(500).json({ + error: 'Internal server error', + details: err.message + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/ai-validation.js b/inventory-server/src/routes/ai-validation.js index 0519ecb..6134f2d 100644 --- a/inventory-server/src/routes/ai-validation.js +++ b/inventory-server/src/routes/ai-validation.js @@ -1 +1,1527 @@ - \ No newline at end of file +const express = require("express"); +const router = express.Router(); +const OpenAI = require("openai"); +const fs = require("fs").promises; +const path = require("path"); +const dotenv = require("dotenv"); +const mysql = require('mysql2/promise'); +const { Client } = require('ssh2'); +const { getDbConnection, closeAllConnections } = require('../utils/dbConnection'); // Import the optimized connection function + +// Ensure environment variables are loaded +dotenv.config({ path: path.join(__dirname, "../../.env") }); + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +if (!process.env.OPENAI_API_KEY) { + console.error("Warning: OPENAI_API_KEY is not set in environment variables"); +} + +async function createResponsesCompletion(payload) { + if (!openai.responses?.create) { + throw new Error( + "OpenAI client does not expose responses.create; please verify the openai SDK version." + ); + } + + return openai.responses.create(payload); +} + +const AI_VALIDATION_SCHEMA_NAME = "ai_validation_response"; + +const FLEXIBLE_PRIMITIVE_SCHEMAS = [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + { type: "null" }, +]; + +const FLEXIBLE_ARRAY_SCHEMA = { + type: "array", + items: { + anyOf: FLEXIBLE_PRIMITIVE_SCHEMAS, + }, +}; + +const FLEXIBLE_OBJECT_SCHEMA = { + type: "object", + properties: {}, + patternProperties: { + ".+": { + anyOf: [...FLEXIBLE_PRIMITIVE_SCHEMAS, FLEXIBLE_ARRAY_SCHEMA], + }, + }, + additionalProperties: false, +}; + +const FLEXIBLE_VALUE_SCHEMA = { + anyOf: [...FLEXIBLE_PRIMITIVE_SCHEMAS, FLEXIBLE_ARRAY_SCHEMA, FLEXIBLE_OBJECT_SCHEMA], +}; + +const AI_VALIDATION_JSON_SCHEMA = { + type: "object", + additionalProperties: false, + required: [ + "correctedData", + "changes", + "warnings", + "summary", + "metadata" + ], + properties: { + correctedData: { + type: "array", + items: { + type: "object", + properties: {}, + patternProperties: { + ".+": FLEXIBLE_VALUE_SCHEMA, + }, + additionalProperties: false, + }, + }, + changes: { + type: "array", + items: { + type: "string", + }, + default: [], + }, + warnings: { + type: "array", + items: { + type: "string", + }, + default: [], + }, + summary: { + type: "string", + default: "", + }, + metadata: { + type: "object", + properties: {}, + patternProperties: { + ".+": FLEXIBLE_VALUE_SCHEMA, + }, + additionalProperties: false, + }, + }, +}; + +const AI_VALIDATION_TEXT_FORMAT = { + type: "json_schema", + name: AI_VALIDATION_SCHEMA_NAME, + strict: true, + schema: AI_VALIDATION_JSON_SCHEMA, +}; + +// Debug endpoint for viewing prompt +router.post("/debug", async (req, res) => { + try { + console.log("Debug POST endpoint called"); + + const { products } = req.body; + + console.log("Received products for debug:", { + isArray: Array.isArray(products), + length: products?.length, + firstProduct: products?.[0], + lastProduct: products?.[products?.length - 1], + }); + + if (!Array.isArray(products)) { + console.error("Invalid input: products is not an array"); + return res.status(400).json({ error: "Products must be an array" }); + } + + if (products.length === 0) { + console.error("Invalid input: products array is empty"); + return res.status(400).json({ error: "Products array cannot be empty" }); + } + + // Clean the products array to remove any internal fields + const cleanedProducts = products.map((product) => { + const { __errors, __index, ...cleanProduct } = product; + return cleanProduct; + }); + + console.log("Processing debug request with cleaned products:", { + length: cleanedProducts.length, + sample: cleanedProducts[0], + }); + + try { + const debugResponse = await generateDebugResponse(cleanedProducts, res); + + // Get estimated processing time based on prompt length + if (debugResponse && debugResponse.promptLength) { + try { + // Use the pool from the app + const pool = req.app.locals.pool; + if (!pool) { + console.warn("⚠️ Local database pool not available for time estimates"); + return; + } + + try { + // Instead of looking for similar prompt lengths, calculate an average processing rate + const rateResults = await pool.query( + `SELECT + AVG(duration_seconds / prompt_length) as avg_rate_per_char, + COUNT(*) as sample_count, + AVG(duration_seconds) as avg_duration + FROM ai_validation_performance` + ); + + // Add estimated time to the response + if (rateResults.rows && rateResults.rows[0] && rateResults.rows[0].avg_rate_per_char) { + // Calculate estimated time based on the rate and current prompt length + const rate = rateResults.rows[0].avg_rate_per_char; + const estimatedSeconds = Math.max(15, Math.round(rate * debugResponse.promptLength)); + + debugResponse.estimatedProcessingTime = { + seconds: estimatedSeconds, + sampleCount: rateResults.rows[0].sample_count || 0, + avgRate: rate, + calculationMethod: "rate-based" + }; + console.log("📊 Calculated time estimate using rate-based method:", { + rate: rate, + promptLength: debugResponse.promptLength, + estimatedSeconds: estimatedSeconds, + sampleCount: rateResults.rows[0].sample_count + }); + } else { + // Fallback: Calculate a simple estimate based on prompt length (1 second per 1000 characters) + const estimatedSeconds = Math.max(15, Math.round(debugResponse.promptLength / 1000)); + console.log("📊 No rate data available, using fallback calculation"); + debugResponse.estimatedProcessingTime = { + seconds: estimatedSeconds, + sampleCount: 0, + isEstimate: true, + calculationMethod: "fallback" + }; + console.log("📊 Fallback time estimate:", debugResponse.estimatedProcessingTime); + } + } catch (queryError) { + console.error("⚠️ Failed to query performance metrics:", queryError); + // Check if table doesn't exist and log a more helpful message + if (queryError.code === '42P01') { + console.error("Table 'ai_validation_performance' does not exist. Make sure to run the setup-schema.sql script."); + } + } + } catch (timeEstimateError) { + console.error("Error getting time estimate:", timeEstimateError); + // Don't fail the request if time estimate fails + } + } + + return res.json(debugResponse); + } catch (generateError) { + console.error("Error generating debug response:", generateError); + return res.status(500).json({ + error: "Error generating debug response: " + generateError.message, + stack: generateError.stack, + name: generateError.name, + code: generateError.code, + sqlMessage: generateError.sqlMessage, + }); + } + } catch (error) { + console.error("Debug POST endpoint error:", error); + res.status(500).json({ + error: error.message, + stack: error.stack, + code: error.code || null, + name: error.name || null + }); + } finally { + try { + await closeAllConnections(); + } catch (closeError) { + console.error("⚠️ Failed to close DB connections after debug request:", closeError); + } + } +}); + +// Helper function to generate debug response +async function generateDebugResponse(productsToUse, res) { + let taxonomy = null; + let mysqlConnection = null; + let ssh = null; + + try { + // Load taxonomy data first + console.log("Loading taxonomy data..."); + try { + // Use optimized database connection + const { connection, ssh: connSsh } = await getDbConnection(); + mysqlConnection = connection; + ssh = connSsh; + + console.log("MySQL connection established successfully using optimized connection"); + + taxonomy = await getTaxonomyData(mysqlConnection); + console.log("Successfully loaded taxonomy data"); + } catch (taxonomyError) { + console.error("Failed to load taxonomy data:", taxonomyError); + return res.status(500).json({ + error: "Error fetching taxonomy data: " + taxonomyError.message, + sqlMessage: taxonomyError.sqlMessage || null, + sqlState: taxonomyError.sqlState || null, + code: taxonomyError.code || null, + errno: taxonomyError.errno || null, + sql: taxonomyError.sql || null, + }); + } + + // Verify the taxonomy data structure + console.log("Verifying taxonomy structure..."); + if (!taxonomy) { + console.error("Taxonomy data is null"); + return res.status(500).json({ error: "Taxonomy data is null" }); + } + + // Check if each taxonomy component exists + const taxonomyComponents = [ + "categories", "themes", "colors", "taxCodes", "sizeCategories", + "suppliers", "companies", "artists", "lines", "subLines" + ]; + + const missingComponents = taxonomyComponents.filter(comp => !taxonomy[comp]); + if (missingComponents.length > 0) { + console.error("Missing taxonomy components:", missingComponents); + } + + // Log detailed taxonomy stats for debugging + console.log("Taxonomy data loaded with details:", { + categories: { + length: taxonomy.categories?.length || 0, + sample: taxonomy.categories?.length > 0 ? JSON.stringify(taxonomy.categories[0]).substring(0, 100) + "..." : null + }, + themes: { + length: taxonomy.themes?.length || 0, + sample: taxonomy.themes?.length > 0 ? JSON.stringify(taxonomy.themes[0]).substring(0, 100) + "..." : null + }, + colors: { + length: taxonomy.colors?.length || 0, + sample: taxonomy.colors?.length > 0 ? JSON.stringify(taxonomy.colors[0]) : null + }, + taxCodes: { + length: taxonomy.taxCodes?.length || 0, + sample: taxonomy.taxCodes?.length > 0 ? JSON.stringify(taxonomy.taxCodes[0]) : null + }, + sizeCategories: { + length: taxonomy.sizeCategories?.length || 0, + sample: taxonomy.sizeCategories?.length > 0 ? JSON.stringify(taxonomy.sizeCategories[0]) : null + }, + suppliers: { + length: taxonomy.suppliers?.length || 0, + sample: taxonomy.suppliers?.length > 0 ? JSON.stringify(taxonomy.suppliers[0]) : null + }, + companies: { + length: taxonomy.companies?.length || 0, + sample: taxonomy.companies?.length > 0 ? JSON.stringify(taxonomy.companies[0]) : null + }, + artists: { + length: taxonomy.artists?.length || 0, + sample: taxonomy.artists?.length > 0 ? JSON.stringify(taxonomy.artists[0]) : null + } + }); + + // Load the prompt using the same function used by validation + console.log("Loading prompt..."); + + // Setup a new connection for loading the prompt + // Use optimized connection instead of creating a new one + const { connection: promptConnection } = await getDbConnection(); + + try { + // Get the local PostgreSQL pool to fetch prompts + const pool = res.app.locals.pool; + if (!pool) { + console.warn("⚠️ Local database pool not available for prompts"); + throw new Error("Database connection not available"); + } + + // First, fetch the system prompt using the consolidated endpoint approach + const systemPromptResult = await pool.query(` + SELECT * FROM ai_prompts + WHERE prompt_type = 'system' + `); + + // Get system prompt or use default + let systemPrompt = null; + if (systemPromptResult.rows.length > 0) { + systemPrompt = systemPromptResult.rows[0]; + console.log("📝 Loaded system prompt from database, ID:", systemPrompt.id); + } else { + console.warn("⚠️ No system prompt found in database, will use default"); + } + + // Then, fetch the general prompt using the consolidated endpoint approach + const generalPromptResult = await pool.query(` + SELECT * FROM ai_prompts + WHERE prompt_type = 'general' + `); + + if (generalPromptResult.rows.length === 0) { + console.warn("⚠️ No general prompt found in database"); + throw new Error("No general prompt found in database"); + } + + // Get the general prompt text and info + const generalPrompt = generalPromptResult.rows[0]; + console.log("📝 Loaded general prompt from database, ID:", generalPrompt.id); + + // Fetch company-specific prompts if we have products to validate + let companyPrompts = []; + if (productsToUse && Array.isArray(productsToUse)) { + // Extract unique company IDs from products + const companyIds = new Set(); + productsToUse.forEach(product => { + if (product.company) { + companyIds.add(String(product.company)); + } + }); + + if (companyIds.size > 0) { + console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds)); + + // Fetch company-specific prompts + const companyPromptsResult = await pool.query(` + SELECT * FROM ai_prompts + WHERE prompt_type = 'company_specific' + AND company = ANY($1) + `, [Array.from(companyIds)]); + + companyPrompts = companyPromptsResult.rows; + console.log(`📝 Loaded ${companyPrompts.length} company-specific prompts`); + } + } + + // Find company names from taxonomy for the validation endpoint + const companyPromptsWithNames = companyPrompts.map(prompt => { + let companyName = "Unknown Company"; + if (taxonomy.companies && Array.isArray(taxonomy.companies)) { + const companyData = taxonomy.companies.find(company => + String(company[0]) === String(prompt.company) + ); + if (companyData && companyData[1]) { + companyName = companyData[1]; + } + } + + return { + id: prompt.id, + company: prompt.company, + companyName: companyName, + prompt_text: prompt.prompt_text + }; + }); + + // Now use loadPrompt to get the actual combined prompt + const promptData = await loadPrompt(promptConnection, productsToUse, res.app.locals.pool); + const fullUserPrompt = promptData.userContent + "\n" + JSON.stringify(productsToUse); + const promptLength = promptData.systemInstructions.length + fullUserPrompt.length; // Store prompt length for performance metrics + console.log("📝 Generated prompt length:", promptLength); + console.log("📝 System instructions length:", promptData.systemInstructions.length); + console.log("📝 User content length:", fullUserPrompt.length); + + // Format the messages as they would be sent to the API + const apiMessages = [ + { + role: "system", + content: promptData.systemInstructions + }, + { + role: "user", + content: fullUserPrompt + } + ]; + + // Create the response with taxonomy stats + let categoriesCount = 0; + try { + categoriesCount = taxonomy?.categories?.length ? countItems(taxonomy.categories) : 0; + } catch (countError) { + console.error("Error counting categories:", countError); + categoriesCount = taxonomy?.categories?.length || 0; // Fallback to simple length + } + + const response = { + taxonomyStats: taxonomy + ? { + categories: categoriesCount, + themes: taxonomy.themes?.length || 0, + colors: taxonomy.colors?.length || 0, + taxCodes: taxonomy.taxCodes?.length || 0, + sizeCategories: taxonomy.sizeCategories?.length || 0, + suppliers: taxonomy.suppliers?.length || 0, + companies: taxonomy.companies?.length || 0, + artists: taxonomy.artists?.length || 0, + // Add filtered counts when products are provided + filtered: productsToUse + ? { + suppliers: taxonomy.suppliers?.filter(([id]) => + productsToUse.some( + (p) => Number(p.supplierid) === Number(id) + ) + )?.length || 0, + companies: taxonomy.companies?.filter(([id]) => + productsToUse.some((p) => Number(p.company) === Number(id)) + )?.length || 0, + artists: taxonomy.artists?.filter(([id]) => + productsToUse.some((p) => Number(p.artist) === Number(id)) + )?.length || 0, + } + : null, + } + : null, + basePrompt: systemPrompt ? systemPrompt.prompt_text + "\n\n" + generalPrompt.prompt_text : generalPrompt.prompt_text, + sampleFullPrompt: fullUserPrompt, + promptLength: promptLength, + apiFormat: apiMessages, + promptSources: { + ...(systemPrompt ? { + systemPrompt: { + id: systemPrompt.id, + prompt_text: systemPrompt.prompt_text + } + } : { + systemPrompt: { + id: 0, + prompt_text: `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.` + } + }), + generalPrompt: { + id: generalPrompt.id, + prompt_text: generalPrompt.prompt_text + }, + companyPrompts: companyPromptsWithNames + } + }; + + console.log("Sending response with taxonomy stats:", response.taxonomyStats); + return response; + } catch (promptLoadError) { + console.error("Error loading prompt:", promptLoadError); + throw promptLoadError; + } + } catch (error) { + console.error("Error generating debug response:", error); + return res.status(500).json({ + error: error.message, + stack: error.stack, + sqlMessage: error.sqlMessage || null, + sqlState: error.sqlState || null, + code: error.code || null, + errno: error.errno || null, + taxonomyState: taxonomy ? "loaded" : "failed", + }); + } +} + +// Helper function to count total items in hierarchical structure +function countItems(items) { + return items.reduce((count, item) => { + return ( + count + 1 + (item.subcategories ? countItems(item.subcategories) : 0) + ); + }, 0); +} + +// Function to fetch and format taxonomy data +async function getTaxonomyData(connection) { + try { + console.log("Starting taxonomy data fetch..."); + // Fetch categories with hierarchy + const [categories] = await connection.query(` +SELECT cat_id,name,NULL AS master_cat_id,1 AS level_order FROM product_categories s WHERE type=10 UNION ALL SELECT c.cat_id,c.name,c.master_cat_id,2 AS level_order FROM product_categories c JOIN product_categories s ON c.master_cat_id=s.cat_id WHERE c.type=11 AND s.type=10 UNION ALL SELECT sc.cat_id,sc.name,sc.master_cat_id,3 AS level_order FROM product_categories sc JOIN product_categories c ON sc.master_cat_id=c.cat_id JOIN product_categories s ON c.master_cat_id=s.cat_id WHERE sc.type=12 AND c.type=11 AND s.type=10 UNION ALL SELECT ssc.cat_id,ssc.name,ssc.master_cat_id,4 AS level_order FROM product_categories ssc JOIN product_categories sc ON ssc.master_cat_id=sc.cat_id JOIN product_categories c ON sc.master_cat_id=c.cat_id JOIN product_categories s ON c.master_cat_id=s.cat_id WHERE ssc.type=13 AND sc.type=12 AND c.type=11 AND s.type=10 ORDER BY level_order,cat_id; + `); + console.log("Categories fetched:", categories.length); + + // Fetch themes with hierarchy + const [themes] = await connection.query(` +SELECT t.cat_id,t.name,null as master_cat_id,1 AS level_order FROM product_categories t WHERE t.type=20 UNION ALL SELECT ts.cat_id,ts.name,ts.master_cat_id,2 AS level_order FROM product_categories ts JOIN product_categories t ON ts.master_cat_id=t.cat_id WHERE ts.type=21 AND t.type=20 ORDER BY level_order,name + `); + console.log("Themes fetched:", themes.length); + + // Fetch colors + const [colors] = await connection.query( + `SELECT color, name, hex_color FROM product_color_list ORDER BY \`order\`` + ); + console.log("Colors fetched:", colors.length); + + // Fetch tax codes + const [taxCodes] = await connection.query( + `SELECT tax_code_id, name FROM product_tax_codes ORDER BY name` + ); + console.log("Tax codes fetched:", taxCodes.length); + + // Fetch size categories + const [sizeCategories] = await connection.query( + `SELECT cat_id, name FROM product_categories WHERE type=50 ORDER BY name` + ); + console.log("Size categories fetched:", sizeCategories.length); + + // Fetch suppliers + const [suppliers] = await connection.query(` + SELECT supplierid, companyname as name + FROM suppliers + WHERE companyname <> '' + ORDER BY companyname + `); + console.log("Suppliers fetched:", suppliers.length); + + // Fetch companies (type 1) + const [companies] = await connection.query(` + SELECT cat_id, name + FROM product_categories + WHERE type = 1 + ORDER BY name + `); + console.log("Companies fetched:", companies.length); + + // Fetch artists (type 40) + const [artists] = await connection.query(` + SELECT cat_id, name + FROM product_categories + WHERE type = 40 + ORDER BY name + `); + console.log("Artists fetched:", artists.length); + + // Fetch lines (type 2) + const [lines] = await connection.query(` + SELECT cat_id, name + FROM product_categories + WHERE type = 2 + ORDER BY name + `); + console.log("Lines fetched:", lines.length); + + // Fetch sub-lines (type 3) + const [subLines] = await connection.query(` + SELECT cat_id, name + FROM product_categories + WHERE type = 3 + ORDER BY name + `); + console.log("Sub-lines fetched:", subLines.length); + + // Format categories into a hierarchical structure + const formatHierarchy = (items, level = 1, parentId = null) => { + return items + .filter( + (item) => + item.level_order === level && item.master_cat_id === parentId + ) + .map((item) => { + const children = formatHierarchy(items, level + 1, item.cat_id); + return children.length > 0 + ? [item.cat_id, item.name, children] + : [item.cat_id, item.name]; + }); + }; + + // Format themes similarly but with only two levels + const formatThemes = (items) => { + return items + .filter((item) => item.level_order === 1) + .map((item) => { + const subthemes = items + .filter((subitem) => subitem.master_cat_id === item.cat_id) + .map((subitem) => [subitem.cat_id, subitem.name]); + return subthemes.length > 0 + ? [item.cat_id, item.name, subthemes] + : [item.cat_id, item.name]; + }); + }; + + // Log first item of each taxonomy category to check structure + console.log("Sample category:", categories.length > 0 ? categories[0] : "No categories"); + console.log("Sample theme:", themes.length > 0 ? themes[0] : "No themes"); + console.log("Sample color:", colors.length > 0 ? colors[0] : "No colors"); + + const formattedData = { + categories: formatHierarchy(categories), + themes: formatThemes(themes), + colors: colors.map((c) => [c.color, c.name, c.hex_color]), + taxCodes: (taxCodes || []).map((tc) => [tc.tax_code_id, tc.name]), + sizeCategories: (sizeCategories || []).map((sc) => [sc.cat_id, sc.name]), + suppliers: suppliers.map((s) => [s.supplierid, s.name]), + companies: companies.map((c) => [c.cat_id, c.name]), + artists: artists.map((a) => [a.cat_id, a.name]), + lines: lines.map((l) => [l.cat_id, l.name]), + subLines: subLines.map((sl) => [sl.cat_id, sl.name]), + }; + + // Check the formatted structure + console.log("Formatted categories count:", formattedData.categories.length); + console.log("Formatted themes count:", formattedData.themes.length); + console.log("Formatted colors count:", formattedData.colors.length); + + return formattedData; + } catch (error) { + console.error("Error fetching taxonomy data:", error); + console.error("Full error details:", { + message: error.message, + stack: error.stack, + code: error.code, + errno: error.errno, + sqlMessage: error.sqlMessage, + sqlState: error.sqlState, + sql: error.sql + }); + + // Instead of silently returning empty arrays, throw the error to be handled by the caller + throw error; + } +} + +// Load prompts from database and inject taxonomy data +async function loadPrompt(connection, productsToValidate = null, appPool = null) { + try { + // Get taxonomy data using the provided MySQL connection + const taxonomy = await getTaxonomyData(connection); + + // Use the provided pool parameter instead of global.app + const pool = appPool; + if (!pool) { + console.warn("⚠️ Local database pool not available for prompts"); + throw new Error("Database connection not available"); + } + + // Fetch the system prompt using the consolidated endpoint approach + const systemPromptResult = await pool.query(` + SELECT * FROM ai_prompts + WHERE prompt_type = 'system' + `); + + // Default system instructions in case the system prompt is not found + let systemInstructions = `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`; + + // If system prompt exists in the database, use it + if (systemPromptResult.rows.length > 0) { + systemInstructions = systemPromptResult.rows[0].prompt_text; + console.log("📝 Loaded system prompt from database"); + } else { + console.warn("⚠️ No system prompt found in database, using default"); + } + + // Fetch the general prompt using the consolidated endpoint approach + const generalPromptResult = await pool.query(` + SELECT * FROM ai_prompts + WHERE prompt_type = 'general' + `); + + if (generalPromptResult.rows.length === 0) { + console.warn("⚠️ No general prompt found in database"); + throw new Error("No general prompt found in database"); + } + + // Get the general prompt text + const basePrompt = generalPromptResult.rows[0].prompt_text; + console.log("📝 Loaded general prompt from database"); + + // Fetch company-specific prompts if we have products to validate + let companyPrompts = []; + if (productsToValidate && Array.isArray(productsToValidate)) { + // Extract unique company IDs from products + const companyIds = new Set(); + productsToValidate.forEach(product => { + if (product.company) { + companyIds.add(String(product.company)); + } + }); + + if (companyIds.size > 0) { + console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds)); + + // Fetch company-specific prompts + const companyPromptsResult = await pool.query(` + SELECT * FROM ai_prompts + WHERE prompt_type = 'company_specific' + AND company = ANY($1) + `, [Array.from(companyIds)]); + + companyPrompts = companyPromptsResult.rows; + console.log(`📝 Loaded ${companyPrompts.length} company-specific prompts`); + } + } + + // Combine prompts - start with the general prompt + let combinedPrompt = basePrompt; + + // Add any company-specific prompts with annotations + if (companyPrompts.length > 0) { + combinedPrompt += "\n\n--- COMPANY-SPECIFIC INSTRUCTIONS ---\n"; + + for (const prompt of companyPrompts) { + // Find company name from taxonomy + let companyName = "Unknown Company"; + if (taxonomy.companies && Array.isArray(taxonomy.companies)) { + const companyData = taxonomy.companies.find(company => + String(company[0]) === String(prompt.company) + ); + if (companyData && companyData[1]) { + companyName = companyData[1]; + } + } + + combinedPrompt += `\n[SPECIFIC TO COMPANY: ${companyName} (ID: ${prompt.company})]:\n${prompt.prompt_text}\n`; + } + + combinedPrompt += "\n--- END COMPANY-SPECIFIC INSTRUCTIONS ---\n"; + } + + // If we have products to validate, create a filtered prompt + if (productsToValidate) { + console.log("Creating filtered prompt for products:", productsToValidate); + + // Extract unique values from products for non-core attributes + const uniqueValues = { + supplierIds: new Set(), + companyIds: new Set(), + artistIds: new Set(), + lineIds: new Set(), + subLineIds: new Set(), + }; + + // Collect any values that exist in the products + productsToValidate.forEach((product) => { + Object.entries(product).forEach(([key, value]) => { + if (value === undefined || value === null) return; + + // Map field names to their respective sets + const fieldMap = { + supplierid: "supplierIds", + supplier: "supplierIds", + company: "companyIds", + artist: "artistIds", + line: "lineIds", + subline: "subLineIds", + }; + + if (fieldMap[key]) { + uniqueValues[fieldMap[key]].add(Number(value)); + } + }); + }); + + console.log("Unique values collected:", { + suppliers: Array.from(uniqueValues.supplierIds), + companies: Array.from(uniqueValues.companyIds), + artists: Array.from(uniqueValues.artistIds), + lines: Array.from(uniqueValues.lineIds), + subLines: Array.from(uniqueValues.subLineIds), + }); + + // Create mixed taxonomy with filtered non-core data and full core data + const mixedTaxonomy = { + // Keep full data for core attributes + categories: taxonomy.categories, + themes: taxonomy.themes, + colors: taxonomy.colors, + taxCodes: taxonomy.taxCodes, + sizeCategories: taxonomy.sizeCategories, + // For non-core data, only include items that are actually used + suppliers: taxonomy.suppliers.filter(([id]) => + uniqueValues.supplierIds.has(Number(id)) + ), + companies: taxonomy.companies.filter(([id]) => + uniqueValues.companyIds.has(Number(id)) + ), + artists: taxonomy.artists.filter(([id]) => + uniqueValues.artistIds.has(Number(id)) + ), + lines: taxonomy.lines.filter(([id]) => + uniqueValues.lineIds.has(Number(id)) + ), + subLines: taxonomy.subLines.filter(([id]) => + uniqueValues.subLineIds.has(Number(id)) + ), + }; + + console.log("Filtered taxonomy counts:", { + suppliers: mixedTaxonomy.suppliers.length, + companies: mixedTaxonomy.companies.length, + artists: mixedTaxonomy.artists.length, + lines: mixedTaxonomy.lines.length, + subLines: mixedTaxonomy.subLines.length, + }); + + // Format taxonomy data for the prompt, only including sections with values + const taxonomySection = ` +All Available Categories: +${JSON.stringify(mixedTaxonomy.categories)} + +All Available Themes: +${JSON.stringify(mixedTaxonomy.themes)} + +All Available Colors: +${JSON.stringify(mixedTaxonomy.colors)} + +All Available Tax Codes: +${JSON.stringify(mixedTaxonomy.taxCodes)} + +All Available Size Categories: +${JSON.stringify(mixedTaxonomy.sizeCategories)}${ + mixedTaxonomy.suppliers.length + ? `\n\nSuppliers Used In This Data:\n${JSON.stringify( + mixedTaxonomy.suppliers + )}` + : "" + }${ + mixedTaxonomy.companies.length + ? `\n\nCompanies Used In This Data:\n${JSON.stringify( + mixedTaxonomy.companies + )}` + : "" + }${ + mixedTaxonomy.artists.length + ? `\n\nArtists Used In This Data:\n${JSON.stringify( + mixedTaxonomy.artists + )}` + : "" + }${ + mixedTaxonomy.lines.length + ? `\n\nLines Used In This Data:\n${JSON.stringify( + mixedTaxonomy.lines + )}` + : "" + }${ + mixedTaxonomy.subLines.length + ? `\n\nSub-Lines Used In This Data:\n${JSON.stringify( + mixedTaxonomy.subLines + )}` + : "" + } + +----------Here is the product data to validate----------`; + + // Return both system instructions and user content separately + return { + systemInstructions, + userContent: combinedPrompt + "\n" + taxonomySection + }; + } + + // Generate the full unfiltered prompt for taxonomy section + const taxonomySection = ` +Available Categories: +${JSON.stringify(taxonomy.categories)} + +Available Themes: +${JSON.stringify(taxonomy.themes)} + +Available Colors: +${JSON.stringify(taxonomy.colors)} + +Available Tax Codes: +${JSON.stringify(taxonomy.taxCodes)} + +Available Size Categories: +${JSON.stringify(taxonomy.sizeCategories)} + +Available Suppliers: +${JSON.stringify(taxonomy.suppliers)} + +Available Companies: +${JSON.stringify(taxonomy.companies)} + +Available Artists: +${JSON.stringify(taxonomy.artists)} + +Here is the product data to validate:`; + + // Return both system instructions and user content separately + return { + systemInstructions, + userContent: combinedPrompt + "\n" + taxonomySection + }; + } catch (error) { + console.error("Error loading prompt:", error); + throw error; // Re-throw to be handled by the calling function + } +} + +router.post("/validate", async (req, res) => { + try { + const { products } = req.body; + const startTime = new Date(); // Track start time for performance metrics + + console.log("🔍 Received products for validation:", { + isArray: Array.isArray(products), + length: products?.length, + firstProduct: products?.[0], + lastProduct: products?.[products?.length - 1], + }); + + if (!Array.isArray(products)) { + console.error("❌ Invalid input: products is not an array"); + return res.status(400).json({ error: "Products must be an array" }); + } + + if (products.length === 0) { + console.error("❌ Invalid input: products array is empty"); + return res.status(400).json({ error: "Products array cannot be empty" }); + } + + let ssh = null; + let connection = null; + let promptLength = 0; // Track prompt length for performance metrics + + try { + // Use the optimized connection utility instead of direct SSH tunnel + console.log("🔄 Setting up connection to production database using optimized connection..."); + const { ssh: connSsh, connection: connDB } = await getDbConnection(); + ssh = connSsh; + connection = connDB; + + console.log("🔄 MySQL connection established successfully"); + + // Load the prompt with the products data to filter taxonomy + console.log("🔄 Loading prompt with filtered taxonomy..."); + const promptData = await loadPrompt(connection, products, req.app.locals.pool); + const fullUserPrompt = promptData.userContent + "\n" + JSON.stringify(products); + promptLength = promptData.systemInstructions.length + fullUserPrompt.length; // Store prompt length for performance metrics + console.log("📝 Generated prompt length:", promptLength); + console.log("📝 System instructions length:", promptData.systemInstructions.length); + console.log("📝 User content length:", fullUserPrompt.length); + + console.log("🤖 Sending request to OpenAI Responses API..."); + + // GPT-5 Responses API Configuration: + // - Using "gpt-5" (reasoning model) for complex product validation + // - reasoning.effort: "medium" balances quality and speed (minimal, low, medium, high) + // - text.verbosity: "medium" provides balanced output detail (low, medium, high) + // - max_output_tokens: 20000 ensures space for large product batches + // Note: Responses API is the recommended endpoint for GPT-5 models + const completion = await createResponsesCompletion({ + model: "gpt-5", + input: [ + { + role: "developer", + content: `${promptData.systemInstructions}\n\nYou MUST respond with a single valid JSON object containing the following top-level keys: correctedData, changes, warnings, summary, metadata.\n- correctedData: array of product objects reflecting the updated data.\n- changes: array of human-readable bullet points summarizing the nature of updates.\n- warnings: array of caveats or risks that still require review.\n- summary: a concise paragraph (<=75 words) describing overall data quality and improvements.\n- metadata: object containing any supplemental machine-readable information (optional fields allowed).\nDo NOT include Markdown code fences or any text outside the JSON object.`, + }, + { + role: "user", + content: fullUserPrompt, + }, + ], + reasoning: { + effort: "medium" + }, + text: { + verbosity: "medium", + format: AI_VALIDATION_TEXT_FORMAT, + }, + max_output_tokens: 20000, + }); + + console.log("✅ Received response from OpenAI Responses API"); + + // Responses API structure: response has 'output' array with message objects + const rawResponse = extractResponseText(completion); + console.log("📄 Raw AI response length:", rawResponse ? rawResponse.length : 0); + + if (!rawResponse) { + throw new Error("OpenAI response did not include any text output"); + } + + const responseModel = completion.model; + const usage = completion.usage || {}; + + // GPT-5 Responses API provides detailed token usage including reasoning tokens + const tokenUsageSummary = { + prompt: usage.input_tokens ?? usage.prompt_tokens ?? null, + completion: usage.output_tokens ?? usage.completion_tokens ?? null, + total: usage.total_tokens ?? null, + // GPT-5 reasoning tokens are in output_tokens_details + reasoning: usage.output_tokens_details?.reasoning_tokens ?? usage.completion_tokens_details?.reasoning_tokens ?? null, + // Also capture text generation tokens separately from reasoning + textGeneration: usage.output_tokens_details?.text_generation_tokens ?? usage.completion_tokens_details?.text_generation_tokens ?? null, + cachedPrompt: usage.input_tokens_details?.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? null, + // Capture audio tokens if present (future GPT-5 feature) + audioTokens: usage.output_tokens_details?.audio_tokens ?? usage.completion_tokens_details?.audio_tokens ?? null, + }; + + // Extract reasoning_effort and verbosity that were actually applied + const reasoningEffortApplied = completion.reasoning?.effort || "medium"; + const verbosityApplied = completion.text?.verbosity || "medium"; + + console.log("📊 Token usage summary:", tokenUsageSummary); + console.log("🤖 Model dispatched:", responseModel); + console.log("🧠 Reasoning effort applied:", reasoningEffortApplied); + console.log("📝 Verbosity applied:", verbosityApplied); + + try { + const normalizedResponse = normalizeJsonResponse(rawResponse); + const aiResponse = JSON.parse(normalizedResponse); + console.log( + "🔄 Parsed AI response with keys:", + Object.keys(aiResponse) + ); + + // Create a detailed comparison between original and corrected data + const changeDetails = []; + + // Compare original and corrected data + if (aiResponse.correctedData) { + console.log("📊 Changes summary:"); + + // Debug: Log the first product's fields + if (products.length > 0) { + console.log("🔍 First product fields:", Object.keys(products[0])); + } + + products.forEach((original, index) => { + const corrected = aiResponse.correctedData[index]; + if (corrected) { + const productChanges = { + productIndex: index, + title: original.name || original.title || `Product ${index + 1}`, + changes: [] + }; + + const changes = Object.keys(corrected).filter( + (key) => + JSON.stringify(original[key]) !== + JSON.stringify(corrected[key]) + ); + + if (changes.length > 0) { + console.log(`\nProduct ${index + 1} changes:`); + changes.forEach((key) => { + console.log(` ${key}:`); + console.log( + ` - Original: ${JSON.stringify(original[key])}` + ); + console.log( + ` - Corrected: ${JSON.stringify(corrected[key])}` + ); + + // Add to our detailed changes array + productChanges.changes.push({ + field: key, + original: original[key], + corrected: corrected[key] + }); + }); + + // Only add products that have changes + if (productChanges.changes.length > 0) { + changeDetails.push(productChanges); + } + } + } + }); + } + + // Record performance metrics after successful validation + const endTime = new Date(); + let performanceMetrics = { + promptLength, + productCount: products.length, + model: responseModel, + tokenUsage: tokenUsageSummary, + reasoningTokens: tokenUsageSummary.reasoning, + reasoningEffort: reasoningEffortApplied, + verbosity: verbosityApplied, + }; + + try { + // Use the local PostgreSQL pool from the app instead of the MySQL connection + const pool = req.app.locals.pool; + if (!pool) { + console.warn("⚠️ Local database pool not available for recording metrics"); + return; + } + + try { + // Insert performance data into the local PostgreSQL database + await pool.query( + `INSERT INTO ai_validation_performance + (prompt_length, product_count, start_time, end_time) + VALUES ($1, $2, $3, $4)`, + [ + promptLength, + products.length, + startTime.toISOString(), + endTime.toISOString() + ] + ); + + console.log("📊 Performance metrics inserted into database"); + + // Query for average processing time based on similar prompt lengths + try { + const rateResults = await pool.query( + `SELECT + AVG(duration_seconds / prompt_length) as avg_rate_per_char, + COUNT(*) as sample_count, + AVG(duration_seconds) as avg_duration + FROM ai_validation_performance` + ); + + if (rateResults.rows && rateResults.rows[0] && rateResults.rows[0].avg_rate_per_char) { + const rate = rateResults.rows[0].avg_rate_per_char; + performanceMetrics.avgRate = rate; + performanceMetrics.estimatedSeconds = Math.round(rate * promptLength); + performanceMetrics.sampleCount = rateResults.rows[0].sample_count; + performanceMetrics.calculationMethod = "rate-based"; + } + + console.log("📊 Performance metrics with rate calculation:", performanceMetrics); + } catch (queryError) { + console.error("⚠️ Failed to query performance metrics:", queryError); + } + } catch (insertError) { + console.error("⚠️ Failed to insert performance metrics:", insertError); + // Check if table doesn't exist and log a more helpful message + if (insertError.code === '42P01') { + console.error("Table 'ai_validation_performance' does not exist. Make sure to run the setup-schema.sql script."); + } + } + } catch (metricError) { + // Don't fail the request if metrics recording fails + console.error("⚠️ Failed to record performance metrics:", metricError); + } + + // Get sources of the prompts for tracking + let promptSources = null; + + try { + // Use the local PostgreSQL pool from the app + const pool = req.app.locals.pool; + if (!pool) { + console.warn("⚠️ Local database pool not available for prompt sources"); + } else { + // Get system prompt + const systemPromptResult = await pool.query(` + SELECT * FROM ai_prompts WHERE prompt_type = 'system' + `); + + // Get general prompt + const generalPromptResult = await pool.query(` + SELECT * FROM ai_prompts WHERE prompt_type = 'general' + `); + + // Extract unique company IDs from products + const companyIds = new Set(); + products.forEach(product => { + if (product.company) { + companyIds.add(String(product.company)); + } + }); + + let companyPrompts = []; + if (companyIds.size > 0) { + // Fetch company-specific prompts + const companyPromptsResult = await pool.query(` + SELECT * FROM ai_prompts + WHERE prompt_type = 'company_specific' + AND company = ANY($1) + `, [Array.from(companyIds)]); + + companyPrompts = companyPromptsResult.rows; + } + + // Format company prompts for response + // Note: Company names would require re-fetching taxonomy data + // For now, we include company ID only + const companyPromptsWithNames = companyPrompts.map(prompt => ({ + id: prompt.id, + company: prompt.company, + prompt_text: prompt.prompt_text + })); + + // Set prompt sources + if (generalPromptResult.rows.length > 0) { + const generalPrompt = generalPromptResult.rows[0]; + let systemPrompt = null; + + if (systemPromptResult.rows.length > 0) { + systemPrompt = systemPromptResult.rows[0]; + } + + promptSources = { + ...(systemPrompt ? { + systemPrompt: { + id: systemPrompt.id, + prompt_text: systemPrompt.prompt_text + } + } : { + systemPrompt: { + id: 0, + prompt_text: `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.` + } + }), + generalPrompt: { + id: generalPrompt.id, + prompt_text: generalPrompt.prompt_text + }, + companyPrompts: companyPromptsWithNames + }; + } + } + } catch (promptSourceError) { + console.error("⚠️ Error getting prompt sources:", promptSourceError); + // Don't fail the entire validation if just prompt sources retrieval fails + } + + // Include prompt sources in the response + res.json({ + success: true, + ...aiResponse, + changeDetails, + performanceMetrics: + performanceMetrics || { + // Fallback: calculate a simple estimate + promptLength, + processingTimeSeconds: Math.max(15, Math.round(promptLength / 1000)), + isEstimate: true, + productCount: products.length, + model: responseModel, + tokenUsage: tokenUsageSummary, + reasoningTokens: tokenUsageSummary.reasoning, + reasoningEffort: reasoningEffortApplied, + verbosity: verbosityApplied, + }, + promptSources, + model: responseModel, + tokenUsage: tokenUsageSummary, + reasoningEffort: reasoningEffortApplied, + verbosity: verbosityApplied, + }); + } catch (parseError) { + console.error("❌ Error parsing AI response:", parseError); + console.error("Raw response that failed to parse:", rawResponse); + res.status(500).json({ + success: false, + error: "Error parsing AI response: " + parseError.message, + }); + } + } catch (openaiError) { + console.error("❌ OpenAI API Error:", openaiError); + res.status(500).json({ + success: false, + error: "OpenAI API Error: " + openaiError.message, + }); + } + } catch (error) { + console.error("❌ AI Validation Error:", error); + console.error("Error details:", { + name: error.name, + message: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + error: error.message || "Error during AI validation", + }); + } finally { + try { + await closeAllConnections(); + } catch (closeError) { + console.error("⚠️ Failed to close DB connections after validation request:", closeError); + } + } +}); + +// Test endpoint for direct database query of taxonomy data +router.get("/test-taxonomy", async (req, res) => { + try { + console.log("Test taxonomy endpoint called"); + + let ssh = null; + let connection = null; + + try { + // Use the optimized connection utility instead of direct SSH tunnel + console.log("🔄 Setting up connection to production database using optimized connection..."); + const { ssh: connSsh, connection: connDB } = await getDbConnection(); + ssh = connSsh; + connection = connDB; + + console.log("MySQL connection established successfully for test"); + + const results = {}; + + // Test categories query + try { + const [categories] = await connection.query(` + SELECT cat_id, name FROM product_categories WHERE type=10 LIMIT 5 + `); + results.categories = { + success: true, + count: categories.length, + sample: categories.length > 0 ? categories[0] : null + }; + } catch (error) { + results.categories = { + success: false, + error: error.message, + sqlMessage: error.sqlMessage + }; + } + + // Test themes query + try { + const [themes] = await connection.query(` + SELECT cat_id, name FROM product_categories WHERE type=20 LIMIT 5 + `); + results.themes = { + success: true, + count: themes.length, + sample: themes.length > 0 ? themes[0] : null + }; + } catch (error) { + results.themes = { + success: false, + error: error.message, + sqlMessage: error.sqlMessage + }; + } + + // Test colors query + try { + const [colors] = await connection.query(` + SELECT color, name, hex_color FROM product_color_list ORDER BY \`order\` LIMIT 5 + `); + results.colors = { + success: true, + count: colors.length, + sample: colors.length > 0 ? colors[0] : null + }; + } catch (error) { + results.colors = { + success: false, + error: error.message, + sqlMessage: error.sqlMessage + }; + } + + return res.json({ + message: "Test taxonomy queries executed", + results: results, + timestamp: new Date().toISOString() + }); + } finally { + try { + await closeAllConnections(); + } catch (closeError) { + console.error("⚠️ Failed to close DB connections after test-taxonomy request:", closeError); + } + } + } catch (error) { + console.error("Test taxonomy endpoint error:", error); + return res.status(500).json({ + error: error.message, + stack: error.stack + }); + } +}); + +module.exports = router; + +function extractResponseText(response) { + if (!response) return ""; + + const outputs = []; + if (Array.isArray(response.output)) { + outputs.push(...response.output); + } + if (Array.isArray(response.outputs)) { + outputs.push(...response.outputs); + } + + const segments = outputs.flatMap((output) => collectTextSegments(output?.content ?? output)); + + if (segments.length === 0 && typeof response.output_text === "string") { + segments.push(response.output_text); + } + + if (segments.length === 0 && response.choices?.length) { + segments.push( + ...collectTextSegments(response.choices?.[0]?.message?.content) + ); + } + + const text = segments.join("").trim(); + return text; +} + +function collectTextSegments(node) { + if (node == null) return []; + + if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") { + return [String(node)]; + } + + if (Array.isArray(node)) { + return node.flatMap(collectTextSegments); + } + + if (typeof node !== "object") { + return []; + } + + const segments = []; + + if (typeof node.text === "string") { + segments.push(node.text); + } else if (Array.isArray(node.text)) { + segments.push(...node.text.flatMap(collectTextSegments)); + } + + if (typeof node.content === "string") { + segments.push(node.content); + } else if (Array.isArray(node.content)) { + segments.push(...node.content.flatMap(collectTextSegments)); + } + + if (typeof node.output_text === "string") { + segments.push(node.output_text); + } else if (Array.isArray(node.output_text)) { + segments.push(...node.output_text.flatMap(collectTextSegments)); + } + + if (typeof node.value === "string") { + segments.push(node.value); + } + + if (typeof node.data === "string") { + segments.push(node.data); + } + + return segments; +} + +function normalizeJsonResponse(text) { + if (!text || typeof text !== 'string') return text; + let cleaned = text.trim(); + + if (cleaned.startsWith('```')) { + const firstLineBreak = cleaned.indexOf('\n'); + if (firstLineBreak !== -1) { + cleaned = cleaned.substring(firstLineBreak + 1); + } else { + cleaned = cleaned.replace(/^```/, ''); + } + + const closingFenceIndex = cleaned.lastIndexOf('```'); + if (closingFenceIndex !== -1) { + cleaned = cleaned.substring(0, closingFenceIndex); + } + + cleaned = cleaned.trim(); + } + + return cleaned; +} diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js index 0519ecb..c36ec62 100644 --- a/inventory-server/src/routes/analytics.js +++ b/inventory-server/src/routes/analytics.js @@ -1 +1,774 @@ - \ No newline at end of file +const express = require('express'); +const router = express.Router(); + +// Forecasting: summarize sales for products received in a period by brand +router.get('/forecast', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const brand = (req.query.brand || '').toString(); + const titleSearch = (req.query.search || req.query.q || '').toString().trim() || null; + const startDateStr = req.query.startDate; + const endDateStr = req.query.endDate; + + if (!brand) { + return res.status(400).json({ error: 'Missing required parameter: brand' }); + } + + // Default to last 30 days if no dates provided + const endDate = endDateStr ? new Date(endDateStr) : new Date(); + const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000); + + // Normalize to date boundaries for consistency + const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString(); + const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString(); + + const sql = ` + WITH params AS ( + SELECT + $1::date AS start_date, + $2::date AS end_date, + $3::text AS brand, + $4::text AS title_search, + (($2::date - $1::date) + 1)::int AS days + ), + category_path AS ( + WITH RECURSIVE cp AS ( + SELECT c.cat_id, c.name, c.parent_id, c.name::text AS path + FROM categories c WHERE c.parent_id IS NULL + UNION ALL + SELECT c.cat_id, c.name, c.parent_id, (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN cp ON c.parent_id = cp.cat_id + ) + SELECT * FROM cp + ), + product_first_received AS ( + SELECT + p.pid, + COALESCE(p.first_received::date, MIN(r.received_date)::date) AS first_received_date + FROM products p + LEFT JOIN receivings r ON r.pid = p.pid + GROUP BY p.pid, p.first_received + ), + recent_products AS ( + SELECT p.pid + FROM products p + JOIN product_first_received fr ON fr.pid = p.pid + JOIN params pr ON 1=1 + WHERE p.visible = true + AND COALESCE(p.brand,'Unbranded') = pr.brand + AND fr.first_received_date BETWEEN pr.start_date AND pr.end_date + AND (pr.title_search IS NULL OR p.title ILIKE '%' || pr.title_search || '%') + ), + product_pick_category AS ( + ( + SELECT DISTINCT ON (pc.pid) + pc.pid, + c.name AS category_name, + COALESCE(cp.path, c.name) AS path + FROM product_categories pc + JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21)) + LEFT JOIN category_path cp ON cp.cat_id = c.cat_id + WHERE pc.pid IN (SELECT pid FROM recent_products) + AND (cp.path IS NULL OR ( + cp.path NOT ILIKE '%Black Friday%' + AND cp.path NOT ILIKE '%Deals%' + )) + AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals') + ORDER BY pc.pid, length(COALESCE(cp.path,'')) DESC + ) + UNION ALL + ( + SELECT + rp.pid, + 'Uncategorized'::text AS category_name, + 'Uncategorized'::text AS path + FROM recent_products rp + WHERE NOT EXISTS ( + SELECT 1 + FROM product_categories pc + JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21)) + LEFT JOIN category_path cp ON cp.cat_id = c.cat_id + WHERE pc.pid = rp.pid + AND (cp.path IS NULL OR ( + cp.path NOT ILIKE '%Black Friday%' + AND cp.path NOT ILIKE '%Deals%' + )) + AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals') + ) + ) + ), + product_sales AS ( + SELECT + p.pid, + p.title, + p.sku, + COALESCE(p.stock_quantity, 0) AS stock_quantity, + COALESCE(p.price, 0) AS price, + COALESCE(SUM(o.quantity), 0) AS total_sold + FROM recent_products rp + JOIN products p ON p.pid = rp.pid + LEFT JOIN params pr ON true + LEFT JOIN orders o ON o.pid = p.pid + AND o.date::date BETWEEN pr.start_date AND pr.end_date + AND (o.canceled IS DISTINCT FROM TRUE) + GROUP BY p.pid, p.title, p.sku, p.stock_quantity, p.price + ) + SELECT + ppc.category_name, + ppc.path, + COUNT(ps.pid) AS num_products, + SUM(ps.total_sold) AS total_sold, + ROUND(AVG(COALESCE(ps.total_sold,0) / NULLIF(pr.days,0)), 2) AS avg_daily_sales, + ROUND(AVG(COALESCE(ps.total_sold,0)), 2) AS avg_total_sold, + MIN(ps.total_sold) AS min_total_sold, + MAX(ps.total_sold) AS max_total_sold, + JSON_AGG( + JSON_BUILD_OBJECT( + 'pid', ps.pid, + 'title', ps.title, + 'sku', ps.sku, + 'total_sold', ps.total_sold, + 'categoryPath', ppc.path + ) + ) AS products + FROM product_sales ps + JOIN product_pick_category ppc ON ppc.pid = ps.pid + JOIN params pr ON true + GROUP BY ppc.category_name, ppc.path + HAVING SUM(ps.total_sold) >= 0 + ORDER BY (ppc.category_name = 'Uncategorized') ASC, avg_total_sold DESC NULLS LAST + LIMIT 200; + `; + + const { rows } = await pool.query(sql, [startISO, endISO, brand, titleSearch]); + + // Normalize/shape response keys to match front-end expectations + const shaped = rows.map(r => ({ + category_name: r.category_name, + path: r.path, + avg_daily_sales: Number(r.avg_daily_sales) || 0, + total_sold: Number(r.total_sold) || 0, + num_products: Number(r.num_products) || 0, + avgTotalSold: Number(r.avg_total_sold) || 0, + minSold: Number(r.min_total_sold) || 0, + maxSold: Number(r.max_total_sold) || 0, + products: Array.isArray(r.products) ? r.products : [] + })); + + res.json(shaped); + } catch (error) { + console.error('Error fetching forecast data:', error); + res.status(500).json({ error: 'Failed to fetch forecast data' }); + } +}); + +// Get overall analytics stats +router.get('/stats', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows: [results] } = await pool.query(` + WITH vendor_count AS ( + SELECT COUNT(DISTINCT vendor_name) AS count + FROM vendor_metrics + ), + category_count AS ( + SELECT COUNT(DISTINCT category_id) AS count + FROM category_metrics + ), + metrics_summary AS ( + SELECT + AVG(margin_30d) AS avg_profit_margin, + AVG(markup_30d) AS avg_markup, + AVG(stockturn_30d) AS avg_stock_turnover, + AVG(asp_30d) AS avg_order_value + FROM product_metrics + WHERE sales_30d > 0 + ) + SELECT + COALESCE(ms.avg_profit_margin, 0) AS profitMargin, + COALESCE(ms.avg_markup, 0) AS averageMarkup, + COALESCE(ms.avg_stock_turnover, 0) AS stockTurnoverRate, + COALESCE(vc.count, 0) AS vendorCount, + COALESCE(cc.count, 0) AS categoryCount, + COALESCE(ms.avg_order_value, 0) AS averageOrderValue + FROM metrics_summary ms + CROSS JOIN vendor_count vc + CROSS JOIN category_count cc + `); + + // Ensure all values are numbers + const stats = { + profitMargin: Number(results.profitmargin) || 0, + averageMarkup: Number(results.averagemarkup) || 0, + stockTurnoverRate: Number(results.stockturnoverrate) || 0, + vendorCount: Number(results.vendorcount) || 0, + categoryCount: Number(results.categorycount) || 0, + averageOrderValue: Number(results.averageordervalue) || 0 + }; + + res.json(stats); + } catch (error) { + console.error('Error fetching analytics stats:', error); + res.status(500).json({ error: 'Failed to fetch analytics stats' }); + } +}); + +// Get profit analysis data +router.get('/profit', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Get profit margins by category with full path + const { rows: byCategory } = await pool.query(` + WITH RECURSIVE category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ) + SELECT + cm.category_name as category, + COALESCE(cp.path, cm.category_name) as categorypath, + cm.avg_margin_30d as profitmargin, + cm.revenue_30d as revenue, + cm.cogs_30d as cost + FROM category_metrics cm + LEFT JOIN category_path cp ON cm.category_id = cp.cat_id + WHERE cm.revenue_30d > 0 + ORDER BY cm.revenue_30d DESC + LIMIT 10 + `); + + // Get profit margin over time + const { rows: overTime } = await pool.query(` + WITH time_series AS ( + SELECT + date_trunc('day', generate_series( + CURRENT_DATE - INTERVAL '30 days', + CURRENT_DATE, + '1 day'::interval + ))::date AS date + ), + daily_profits AS ( + SELECT + snapshot_date as date, + SUM(net_revenue) as revenue, + SUM(cogs) as cost, + CASE + WHEN SUM(net_revenue) > 0 + THEN (SUM(net_revenue - cogs) / SUM(net_revenue)) * 100 + ELSE 0 + END as profit_margin + FROM daily_product_snapshots + WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY snapshot_date + ) + SELECT + to_char(ts.date, 'YYYY-MM-DD') as date, + COALESCE(dp.profit_margin, 0) as profitmargin, + COALESCE(dp.revenue, 0) as revenue, + COALESCE(dp.cost, 0) as cost + FROM time_series ts + LEFT JOIN daily_profits dp ON ts.date = dp.date + ORDER BY ts.date + `); + + // Get top performing products by profit margin + const { rows: topProducts } = await pool.query(` + WITH RECURSIVE category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ), + product_categories AS ( + SELECT + pc.pid, + c.name as category, + COALESCE(cp.path, c.name) as categorypath + FROM product_categories pc + JOIN categories c ON pc.cat_id = c.cat_id + LEFT JOIN category_path cp ON c.cat_id = cp.cat_id + ) + SELECT + pm.title as product, + COALESCE(pc.category, 'Uncategorized') as category, + COALESCE(pc.categorypath, 'Uncategorized') as categorypath, + pm.margin_30d as profitmargin, + pm.revenue_30d as revenue, + pm.cogs_30d as cost + FROM product_metrics pm + LEFT JOIN product_categories pc ON pm.pid = pc.pid + WHERE pm.revenue_30d > 100 + AND pm.margin_30d > 0 + ORDER BY pm.margin_30d DESC + LIMIT 10 + `); + + res.json({ byCategory, overTime, topProducts }); + } catch (error) { + console.error('Error fetching profit analysis:', error); + res.status(500).json({ error: 'Failed to fetch profit analysis' }); + } +}); + +// Get vendor performance data +router.get('/vendors', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Set cache control headers to prevent 304 + res.set({ + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }); + + console.log('Fetching vendor performance data...'); + + // Get vendor performance metrics from the vendor_metrics table + const { rows: rawPerformance } = await pool.query(` + SELECT + vendor_name as vendor, + revenue_30d as sales_volume, + avg_margin_30d as profit_margin, + COALESCE( + sales_30d / NULLIF(current_stock_units, 0), + 0 + ) as stock_turnover, + product_count, + -- Use actual growth metrics from the vendor_metrics table + sales_growth_30d_vs_prev as growth + FROM vendor_metrics + WHERE revenue_30d > 0 + ORDER BY revenue_30d DESC + LIMIT 20 + `); + + // Format the performance data + const performance = rawPerformance.map(vendor => ({ + vendor: vendor.vendor, + salesVolume: Number(vendor.sales_volume) || 0, + profitMargin: Number(vendor.profit_margin) || 0, + stockTurnover: Number(vendor.stock_turnover) || 0, + productCount: Number(vendor.product_count) || 0, + growth: Number(vendor.growth) || 0 + })); + + // Get vendor comparison metrics (sales per product vs margin) + const { rows: rawComparison } = await pool.query(` + SELECT + vendor_name as vendor, + CASE + WHEN active_product_count > 0 + THEN revenue_30d / active_product_count + ELSE 0 + END as sales_per_product, + avg_margin_30d as average_margin, + product_count as size + FROM vendor_metrics + WHERE active_product_count > 0 + ORDER BY sales_per_product DESC + LIMIT 10 + `); + + // Transform comparison data + const comparison = rawComparison.map(item => ({ + vendor: item.vendor, + salesPerProduct: Number(item.sales_per_product) || 0, + averageMargin: Number(item.average_margin) || 0, + size: Number(item.size) || 0 + })); + + console.log('Performance data ready. Sending response...'); + + // Return complete structure that the front-end expects + res.json({ + performance, + comparison, + // Add empty trends array to complete the structure + trends: [] + }); + } catch (error) { + console.error('Error fetching vendor performance:', error); + res.status(500).json({ error: 'Failed to fetch vendor performance data' }); + } +}); + +// Get stock analysis data +router.get('/stock', async (req, res) => { + try { + const pool = req.app.locals.pool; + console.log('Fetching stock analysis data...'); + + // Use the new metrics tables to get data + + // Get turnover by category + const { rows: turnoverByCategory } = await pool.query(` + WITH category_metrics_with_path AS ( + WITH RECURSIVE category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ) + SELECT + cm.category_id, + cm.category_name, + cp.path as category_path, + cm.current_stock_units, + cm.sales_30d, + cm.stock_turn_30d + FROM category_metrics cm + LEFT JOIN category_path cp ON cm.category_id = cp.cat_id + WHERE cm.sales_30d > 0 + ) + SELECT + category_name as category, + COALESCE(stock_turn_30d, 0) as turnoverRate, + current_stock_units as averageStock, + sales_30d as totalSales + FROM category_metrics_with_path + ORDER BY stock_turn_30d DESC NULLS LAST + LIMIT 10 + `); + + // Get stock levels over time (last 30 days) + const { rows: stockLevels } = await pool.query(` + WITH date_range AS ( + SELECT generate_series( + CURRENT_DATE - INTERVAL '30 days', + CURRENT_DATE, + '1 day'::interval + )::date AS date + ), + daily_stock_counts AS ( + SELECT + snapshot_date, + COUNT(DISTINCT pid) as total_products, + COUNT(DISTINCT CASE WHEN eod_stock_quantity > 5 THEN pid END) as in_stock, + COUNT(DISTINCT CASE WHEN eod_stock_quantity <= 5 AND eod_stock_quantity > 0 THEN pid END) as low_stock, + COUNT(DISTINCT CASE WHEN eod_stock_quantity = 0 THEN pid END) as out_of_stock + FROM daily_product_snapshots + WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY snapshot_date + ) + SELECT + to_char(dr.date, 'YYYY-MM-DD') as date, + COALESCE(dsc.in_stock, 0) as inStock, + COALESCE(dsc.low_stock, 0) as lowStock, + COALESCE(dsc.out_of_stock, 0) as outOfStock + FROM date_range dr + LEFT JOIN daily_stock_counts dsc ON dr.date = dsc.snapshot_date + ORDER BY dr.date + `); + + // Get critical items (products that need reordering) + const { rows: criticalItems } = await pool.query(` + SELECT + pm.title as product, + pm.sku as sku, + pm.current_stock as stockQuantity, + COALESCE(pm.config_safety_stock, 0) as reorderPoint, + COALESCE(pm.stockturn_30d, 0) as turnoverRate, + CASE + WHEN pm.sales_velocity_daily > 0 + THEN ROUND(pm.current_stock / pm.sales_velocity_daily) + ELSE 999 + END as daysUntilStockout + FROM product_metrics pm + WHERE pm.is_visible = true + AND pm.is_replenishable = true + AND pm.sales_30d > 0 + AND pm.current_stock <= pm.config_safety_stock * 2 + ORDER BY + CASE + WHEN pm.sales_velocity_daily > 0 + THEN pm.current_stock / pm.sales_velocity_daily + ELSE 999 + END ASC, + pm.revenue_30d DESC + LIMIT 10 + `); + + res.json({ + turnoverByCategory, + stockLevels, + criticalItems + }); + } catch (error) { + console.error('Error fetching stock analysis:', error); + res.status(500).json({ error: 'Failed to fetch stock analysis', details: error.message }); + } +}); + +// Get price analysis data +router.get('/pricing', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Get price points analysis + const { rows: pricePoints } = await pool.query(` + SELECT + CAST(p.price AS DECIMAL(15,3)) as price, + CAST(SUM(o.quantity) AS DECIMAL(15,3)) as salesVolume, + CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue, + c.name as category + FROM products p + LEFT JOIN orders o ON p.pid = o.pid + JOIN product_categories pc ON p.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY p.price, c.name + HAVING SUM(o.quantity) > 0 + ORDER BY revenue DESC + LIMIT 50 + `); + + // Get price elasticity data (price changes vs demand) + const { rows: elasticity } = await pool.query(` + SELECT + to_char(o.date, 'YYYY-MM-DD') as date, + CAST(AVG(o.price) AS DECIMAL(15,3)) as price, + CAST(SUM(o.quantity) AS DECIMAL(15,3)) as demand + FROM orders o + WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY to_char(o.date, 'YYYY-MM-DD') + ORDER BY date + `); + + // Get price optimization recommendations + const { rows: recommendations } = await pool.query(` + SELECT + p.title as product, + CAST(p.price AS DECIMAL(15,3)) as currentPrice, + CAST( + ROUND( + CASE + WHEN AVG(o.quantity) > 10 THEN p.price * 1.1 + WHEN AVG(o.quantity) < 2 THEN p.price * 0.9 + ELSE p.price + END, 2 + ) AS DECIMAL(15,3) + ) as recommendedPrice, + CAST( + ROUND( + SUM(o.price * o.quantity) * + CASE + WHEN AVG(o.quantity) > 10 THEN 1.15 + WHEN AVG(o.quantity) < 2 THEN 0.95 + ELSE 1 + END, 2 + ) AS DECIMAL(15,3) + ) as potentialRevenue, + CASE + WHEN AVG(o.quantity) > 10 THEN 85 + WHEN AVG(o.quantity) < 2 THEN 75 + ELSE 65 + END as confidence + FROM products p + LEFT JOIN orders o ON p.pid = o.pid + WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY p.pid, p.price, p.title + HAVING ABS( + CAST( + ROUND( + CASE + WHEN AVG(o.quantity) > 10 THEN p.price * 1.1 + WHEN AVG(o.quantity) < 2 THEN p.price * 0.9 + ELSE p.price + END, 2 + ) AS DECIMAL(15,3) + ) - CAST(p.price AS DECIMAL(15,3)) + ) > 0 + ORDER BY + CAST( + ROUND( + SUM(o.price * o.quantity) * + CASE + WHEN AVG(o.quantity) > 10 THEN 1.15 + WHEN AVG(o.quantity) < 2 THEN 0.95 + ELSE 1 + END, 2 + ) AS DECIMAL(15,3) + ) - CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) DESC + LIMIT 10 + `); + + res.json({ pricePoints, elasticity, recommendations }); + } catch (error) { + console.error('Error fetching price analysis:', error); + res.status(500).json({ error: 'Failed to fetch price analysis' }); + } +}); + +// Get category performance data +router.get('/categories', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Common CTE for category paths + const categoryPathCTE = ` + WITH RECURSIVE category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ) + `; + + // Get category performance metrics with full path + const { rows: performance } = await pool.query(` + ${categoryPathCTE}, + monthly_sales AS ( + SELECT + c.name, + cp.path, + SUM(CASE + WHEN o.date >= CURRENT_DATE - INTERVAL '30 days' + THEN o.price * o.quantity + ELSE 0 + END) as current_month, + SUM(CASE + WHEN o.date >= CURRENT_DATE - INTERVAL '60 days' + AND o.date < CURRENT_DATE - INTERVAL '30 days' + THEN o.price * o.quantity + ELSE 0 + END) as previous_month + FROM products p + LEFT JOIN orders o ON p.pid = o.pid + JOIN product_categories pc ON p.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + JOIN category_path cp ON c.cat_id = cp.cat_id + WHERE o.date >= CURRENT_DATE - INTERVAL '60 days' + GROUP BY c.name, cp.path + ) + SELECT + c.name as category, + cp.path as categoryPath, + SUM(o.price * o.quantity) as revenue, + SUM(o.price * o.quantity - p.cost_price * o.quantity) as profit, + ROUND( + ((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100, + 1 + ) as growth, + COUNT(DISTINCT p.pid) as productCount + FROM products p + LEFT JOIN orders o ON p.pid = o.pid + JOIN product_categories pc ON p.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + JOIN category_path cp ON c.cat_id = cp.cat_id + LEFT JOIN monthly_sales ms ON c.name = ms.name AND cp.path = ms.path + WHERE o.date >= CURRENT_DATE - INTERVAL '60 days' + GROUP BY c.name, cp.path, ms.current_month, ms.previous_month + HAVING SUM(o.price * o.quantity) > 0 + ORDER BY revenue DESC + LIMIT 10 + `); + + // Get category revenue distribution with full path + const { rows: distribution } = await pool.query(` + ${categoryPathCTE} + SELECT + c.name as category, + cp.path as categoryPath, + SUM(o.price * o.quantity) as value + FROM products p + LEFT JOIN orders o ON p.pid = o.pid + JOIN product_categories pc ON p.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + JOIN category_path cp ON c.cat_id = cp.cat_id + WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY c.name, cp.path + HAVING SUM(o.price * o.quantity) > 0 + ORDER BY value DESC + LIMIT 6 + `); + + // Get category sales trends with full path + const { rows: trends } = await pool.query(` + ${categoryPathCTE} + SELECT + c.name as category, + cp.path as categoryPath, + to_char(o.date, 'Mon YYYY') as month, + SUM(o.price * o.quantity) as sales + FROM products p + LEFT JOIN orders o ON p.pid = o.pid + JOIN product_categories pc ON p.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + JOIN category_path cp ON c.cat_id = cp.cat_id + WHERE o.date >= CURRENT_DATE - INTERVAL '6 months' + GROUP BY + c.name, + cp.path, + to_char(o.date, 'Mon YYYY'), + to_char(o.date, 'YYYY-MM') + ORDER BY + c.name, + to_char(o.date, 'YYYY-MM') + `); + + res.json({ performance, distribution, trends }); + } catch (error) { + console.error('Error fetching category performance:', error); + res.status(500).json({ error: 'Failed to fetch category performance' }); + } +}); + +module.exports = router; diff --git a/inventory-server/src/routes/brandsAggregate.js b/inventory-server/src/routes/brandsAggregate.js index 0519ecb..587e730 100644 --- a/inventory-server/src/routes/brandsAggregate.js +++ b/inventory-server/src/routes/brandsAggregate.js @@ -1 +1,284 @@ - \ No newline at end of file +const express = require('express'); +const router = express.Router(); +const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed + +// --- Configuration & Helpers --- +const DEFAULT_PAGE_LIMIT = 50; +const MAX_PAGE_LIMIT = 200; + +// Maps query keys to DB columns in brand_metrics +const COLUMN_MAP = { + brandName: { dbCol: 'bm.brand_name', type: 'string' }, + productCount: { dbCol: 'bm.product_count', type: 'number' }, + activeProductCount: { dbCol: 'bm.active_product_count', type: 'number' }, + replenishableProductCount: { dbCol: 'bm.replenishable_product_count', type: 'number' }, + currentStockUnits: { dbCol: 'bm.current_stock_units', type: 'number' }, + currentStockCost: { dbCol: 'bm.current_stock_cost', type: 'number' }, + currentStockRetail: { dbCol: 'bm.current_stock_retail', type: 'number' }, + sales7d: { dbCol: 'bm.sales_7d', type: 'number' }, + revenue7d: { dbCol: 'bm.revenue_7d', type: 'number' }, + sales30d: { dbCol: 'bm.sales_30d', type: 'number' }, + revenue30d: { dbCol: 'bm.revenue_30d', type: 'number' }, + profit30d: { dbCol: 'bm.profit_30d', type: 'number' }, + cogs30d: { dbCol: 'bm.cogs_30d', type: 'number' }, + sales365d: { dbCol: 'bm.sales_365d', type: 'number' }, + revenue365d: { dbCol: 'bm.revenue_365d', type: 'number' }, + lifetimeSales: { dbCol: 'bm.lifetime_sales', type: 'number' }, + lifetimeRevenue: { dbCol: 'bm.lifetime_revenue', type: 'number' }, + avgMargin30d: { dbCol: 'bm.avg_margin_30d', type: 'number' }, + // Growth metrics + salesGrowth30dVsPrev: { dbCol: 'bm.sales_growth_30d_vs_prev', type: 'number' }, + revenueGrowth30dVsPrev: { dbCol: 'bm.revenue_growth_30d_vs_prev', type: 'number' }, + // Add aliases if needed + name: { dbCol: 'bm.brand_name', type: 'string' }, + // Add status for filtering + status: { dbCol: 'brand_status', type: 'string' }, +}; + +function getSafeColumnInfo(queryParamKey) { + return COLUMN_MAP[queryParamKey] || null; +} + +// --- Route Handlers --- + +// GET /brands-aggregate/filter-options (Just brands list for now) +router.get('/filter-options', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /brands-aggregate/filter-options'); + try { + // Get brand names + const { rows: brandRows } = await pool.query(` + SELECT DISTINCT brand_name FROM public.brand_metrics ORDER BY brand_name + `); + + // Get status values - calculate them since they're derived + const { rows: statusRows } = await pool.query(` + SELECT DISTINCT + CASE + WHEN active_product_count > 0 AND sales_30d > 0 THEN 'active' + WHEN active_product_count > 0 THEN 'inactive' + ELSE 'pending' + END as status + FROM public.brand_metrics + ORDER BY status + `); + + res.json({ + brands: brandRows.map(r => r.brand_name), + statuses: statusRows.map(r => r.status) + }); + } catch(error) { + console.error('Error fetching brand filter options:', error); + res.status(500).json({ error: 'Failed to fetch filter options' }); + } +}); + +// GET /brands-aggregate/stats (Overall brand stats) +router.get('/stats', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /brands-aggregate/stats'); + try { + const { rows: [stats] } = await pool.query(` + SELECT + COUNT(*) AS total_brands, + COUNT(CASE WHEN active_product_count > 0 THEN 1 END) AS active_brands, + SUM(active_product_count) AS total_active_products, + SUM(current_stock_cost) AS total_stock_value, + -- Weighted Average Margin + SUM(profit_30d) * 100.0 / NULLIF(SUM(revenue_30d), 0) AS overall_avg_margin_weighted + FROM public.brand_metrics bm + `); + + res.json({ + totalBrands: parseInt(stats?.total_brands || 0), + activeBrands: parseInt(stats?.active_brands || 0), + totalActiveProducts: parseInt(stats?.total_active_products || 0), + totalValue: parseFloat(stats?.total_stock_value || 0), + avgMargin: parseFloat(stats?.overall_avg_margin_weighted || 0), + }); + } catch (error) { + console.error('Error fetching brand stats:', error); + res.status(500).json({ error: 'Failed to fetch brand stats.' }); + } +}); + +// GET /brands-aggregate/ (List brands) +router.get('/', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /brands-aggregate received query:', req.query); + try { + // --- Pagination --- + let page = parseInt(req.query.page, 10) || 1; + let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT; + limit = Math.min(limit, MAX_PAGE_LIMIT); + const offset = (page - 1) * limit; + + // --- Sorting --- + const sortQueryKey = req.query.sort || 'brandName'; // Default sort + const sortColumnInfo = getSafeColumnInfo(sortQueryKey); + const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'bm.brand_name'; + const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST'); + const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`; + + // --- Filtering --- + const conditions = []; + const params = []; + let paramCounter = 1; + // Build conditions based on req.query, using COLUMN_MAP and parseValue + for (const key in req.query) { + if (['page', 'limit', 'sort', 'order'].includes(key)) continue; + + let filterKey = key; + let operator = '='; // Default operator + const value = req.query[key]; + + const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/); + if (operatorMatch) { + filterKey = operatorMatch[1]; + operator = operatorMatch[2]; + } + + const columnInfo = getSafeColumnInfo(filterKey); + if (columnInfo) { + const dbColumn = columnInfo.dbCol; + const valueType = columnInfo.type; + try { + let conditionFragment = ''; + let needsParam = true; + switch (operator.toLowerCase()) { // Normalize operator + case 'eq': operator = '='; break; + case 'ne': operator = '<>'; break; + case 'gt': operator = '>'; break; + case 'gte': operator = '>='; break; + case 'lt': operator = '<'; break; + case 'lte': operator = '<='; break; + case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break; + case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break; + case 'between': + const [val1, val2] = String(value).split(','); + if (val1 !== undefined && val2 !== undefined) { + conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`; + params.push(parseValue(val1, valueType), parseValue(val2, valueType)); + needsParam = false; + } else continue; + break; + case 'in': + const inValues = String(value).split(','); + if (inValues.length > 0) { + const placeholders = inValues.map(() => `$${paramCounter++}`).join(', '); + conditionFragment = `${dbColumn} IN (${placeholders})`; + params.push(...inValues.map(v => parseValue(v, valueType))); + needsParam = false; + } else continue; + break; + default: operator = '='; break; + } + + if (needsParam) { + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; + params.push(parseValue(value, valueType)); + } else if (!conditionFragment) { // For LIKE/ILIKE + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; + } + + + if (conditionFragment) { + conditions.push(`(${conditionFragment})`); + } + } catch (parseError) { + console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`); + if (needsParam) paramCounter--; + } + } else { + console.warn(`Invalid filter key ignored: ${key}`); + } + } + + + // --- Execute Queries --- + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Status calculation similar to vendors + const statusCase = ` + CASE + WHEN active_product_count > 0 AND sales_30d > 0 THEN 'active' + WHEN active_product_count > 0 THEN 'inactive' + ELSE 'pending' + END as brand_status + `; + + const baseSql = ` + FROM ( + SELECT + bm.*, + ${statusCase} + FROM public.brand_metrics bm + ) bm + ${whereClause} + `; + + const countSql = `SELECT COUNT(*) AS total ${baseSql}`; + const dataSql = ` + WITH brand_data AS ( + SELECT + bm.*, + ${statusCase} + FROM public.brand_metrics bm + ) + SELECT bm.* + FROM brand_data bm + ${whereClause} + ${sortClause} + LIMIT $${paramCounter} OFFSET $${paramCounter + 1} + `; + const dataParams = [...params, limit, offset]; + + console.log("Count SQL:", countSql, params); + console.log("Data SQL:", dataSql, dataParams); + + const [countResult, dataResult] = await Promise.all([ + pool.query(countSql, params), + pool.query(dataSql, dataParams) + ]); + + const total = parseInt(countResult.rows[0].total, 10); + const brands = dataResult.rows.map(row => { + // Create a new object with both snake_case and camelCase keys + const transformedRow = { ...row }; // Start with original data + + for (const key in row) { + // Skip null/undefined values + if (row[key] === null || row[key] === undefined) { + continue; // Original already has the null value + } + + // Transform keys to match frontend expectations (add camelCase versions) + // First handle cases like sales_7d -> sales7d + let camelKey = key.replace(/_(\d+[a-z])/g, '$1'); + + // Then handle regular snake_case -> camelCase + camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + if (camelKey !== key) { // Only add if different from original + transformedRow[camelKey] = row[key]; + } + } + return transformedRow; + }); + + // --- Respond --- + res.json({ + brands, + pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit }, + }); + + } catch (error) { + console.error('Error fetching brand metrics list:', error); + res.status(500).json({ error: 'Failed to fetch brand metrics.' }); + } +}); + +// GET /brands-aggregate/:name (Get single brand metric) +// Implement if needed, remember to URL-decode the name parameter + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/categoriesAggregate.js b/inventory-server/src/routes/categoriesAggregate.js new file mode 100644 index 0000000..7a9f0da --- /dev/null +++ b/inventory-server/src/routes/categoriesAggregate.js @@ -0,0 +1,363 @@ +const express = require('express'); +const router = express.Router(); +const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed + +// --- Configuration & Helpers --- +const DEFAULT_PAGE_LIMIT = 50; +const MAX_PAGE_LIMIT = 5000; // Increase this to allow retrieving all categories in one request + +// Maps query keys to DB columns in category_metrics and categories tables +const COLUMN_MAP = { + categoryId: { dbCol: 'cm.category_id', type: 'integer' }, + categoryName: { dbCol: 'cm.category_name', type: 'string' }, // From aggregate table + categoryType: { dbCol: 'cm.category_type', type: 'integer' }, // From aggregate table + parentId: { dbCol: 'cm.parent_id', type: 'integer' }, // From aggregate table + parentName: { dbCol: 'p.name', type: 'string' }, // Requires JOIN to categories + productCount: { dbCol: 'cm.product_count', type: 'number' }, + activeProductCount: { dbCol: 'cm.active_product_count', type: 'number' }, + replenishableProductCount: { dbCol: 'cm.replenishable_product_count', type: 'number' }, + currentStockUnits: { dbCol: 'cm.current_stock_units', type: 'number' }, + currentStockCost: { dbCol: 'cm.current_stock_cost', type: 'number' }, + currentStockRetail: { dbCol: 'cm.current_stock_retail', type: 'number' }, + sales7d: { dbCol: 'cm.sales_7d', type: 'number' }, + revenue7d: { dbCol: 'cm.revenue_7d', type: 'number' }, + sales30d: { dbCol: 'cm.sales_30d', type: 'number' }, + revenue30d: { dbCol: 'cm.revenue_30d', type: 'number' }, + profit30d: { dbCol: 'cm.profit_30d', type: 'number' }, + cogs30d: { dbCol: 'cm.cogs_30d', type: 'number' }, + sales365d: { dbCol: 'cm.sales_365d', type: 'number' }, + revenue365d: { dbCol: 'cm.revenue_365d', type: 'number' }, + lifetimeSales: { dbCol: 'cm.lifetime_sales', type: 'number' }, + lifetimeRevenue: { dbCol: 'cm.lifetime_revenue', type: 'number' }, + avgMargin30d: { dbCol: 'cm.avg_margin_30d', type: 'number' }, + stockTurn30d: { dbCol: 'cm.stock_turn_30d', type: 'number' }, + // Growth metrics + salesGrowth30dVsPrev: { dbCol: 'cm.sales_growth_30d_vs_prev', type: 'number' }, + revenueGrowth30dVsPrev: { dbCol: 'cm.revenue_growth_30d_vs_prev', type: 'number' }, + // Add status from the categories table for filtering + status: { dbCol: 'c.status', type: 'string' }, +}; + +function getSafeColumnInfo(queryParamKey) { + return COLUMN_MAP[queryParamKey] || null; +} + +// Type Labels (Consider moving to a shared config or fetching from DB) +const TYPE_LABELS = { + 10: 'Section', 11: 'Category', 12: 'Subcategory', 13: 'Sub-subcategory', + 1: 'Company', 2: 'Line', 3: 'Subline', 40: 'Artist', // From old schema comments + 20: 'Theme', 21: 'Subtheme' // Additional types from categories.js +}; + +// --- Route Handlers --- + +// GET /categories-aggregate/filter-options +router.get('/filter-options', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /categories-aggregate/filter-options'); + try { + // Fetch distinct types directly from the aggregate table if reliable + // Or join with categories table if source of truth is needed + const { rows: typeRows } = await pool.query(` + SELECT DISTINCT category_type + FROM public.category_metrics + ORDER BY category_type + `); + + const typeOptions = typeRows.map(r => ({ + value: r.category_type, + label: TYPE_LABELS[r.category_type] || `Type ${r.category_type}` // Add labels + })); + + // Add status options for filtering (from categories.js) + const { rows: statusRows } = await pool.query(` + SELECT DISTINCT status FROM public.categories ORDER BY status + `); + + // Get type counts (from categories.js) + const { rows: typeCounts } = await pool.query(` + SELECT + type, + COUNT(*)::integer as count + FROM categories + GROUP BY type + ORDER BY type + `); + + res.json({ + types: typeOptions, + statuses: statusRows.map(r => r.status), + typeCounts: typeCounts.map(tc => ({ + type: tc.type, + count: tc.count + })) + }); + } catch (error) { + console.error('Error fetching category filter options:', error); + res.status(500).json({ error: 'Failed to fetch filter options' }); + } +}); + +// GET /categories-aggregate/stats +router.get('/stats', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /categories-aggregate/stats'); + try { + // Calculate stats directly from the aggregate table + const { rows: [stats] } = await pool.query(` + SELECT + COUNT(*) AS total_categories, + -- Count active based on the source categories table status + COUNT(CASE WHEN c.status = 'active' THEN cm.category_id END) AS active_categories, + SUM(cm.active_product_count) AS total_active_products, -- Sum from aggregates + SUM(cm.current_stock_cost) AS total_stock_value, -- Sum from aggregates + -- Weighted Average Margin (Revenue as weight) + SUM(cm.profit_30d) * 100.0 / NULLIF(SUM(cm.revenue_30d), 0) AS overall_avg_margin_weighted, + -- Simple Average Margin (less accurate if categories vary greatly in size) + AVG(NULLIF(cm.avg_margin_30d, 0)) AS overall_avg_margin_simple + -- Growth rate can be calculated from 30d vs previous 30d revenue if needed + FROM public.category_metrics cm + JOIN public.categories c ON cm.category_id = c.cat_id -- Join to check category status + `); + + res.json({ + totalCategories: parseInt(stats?.total_categories || 0), + activeCategories: parseInt(stats?.active_categories || 0), // Based on categories.status + totalActiveProducts: parseInt(stats?.total_active_products || 0), + totalValue: parseFloat(stats?.total_stock_value || 0), + // Choose which avg margin calculation to expose + avgMargin: parseFloat(stats?.overall_avg_margin_weighted || stats?.overall_avg_margin_simple || 0) + // Growth rate could be added if we implement the calculation + }); + } catch (error) { + console.error('Error fetching category stats:', error); + res.status(500).json({ error: 'Failed to fetch category stats.' }); + } +}); + +// GET /categories-aggregate/ (List categories) +router.get('/', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /categories-aggregate received query:', req.query); + try { + // --- Pagination --- + let page = parseInt(req.query.page, 10) || 1; + let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT; + limit = Math.min(limit, MAX_PAGE_LIMIT); + const offset = (page - 1) * limit; + + // --- Sorting --- + const sortQueryKey = req.query.sort || 'categoryName'; + const sortColumnInfo = getSafeColumnInfo(sortQueryKey); + + // Hierarchical sorting logic from categories.js + const hierarchicalSortOrder = ` + ORDER BY + CASE + WHEN cm.category_type = 10 THEN 1 -- sections first + WHEN cm.category_type = 11 THEN 2 -- categories second + WHEN cm.category_type = 12 THEN 3 -- subcategories third + WHEN cm.category_type = 13 THEN 4 -- subsubcategories fourth + WHEN cm.category_type = 20 THEN 5 -- themes fifth + WHEN cm.category_type = 21 THEN 6 -- subthemes last + ELSE 7 + END, + cm.category_name ASC + `; + + // Use hierarchical sort as default + let sortClause = hierarchicalSortOrder; + + // Override with custom sort if specified + if (sortColumnInfo && sortQueryKey !== 'categoryName') { + const sortColumn = sortColumnInfo.dbCol; + const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST'); + sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`; + } + + // --- Filtering --- + const conditions = []; + const params = []; + let paramCounter = 1; + + console.log("Starting to process filters from query:", req.query); + + // Add filters based on req.query using COLUMN_MAP and parseValue + for (const key in req.query) { + if (['page', 'limit', 'sort', 'order'].includes(key)) continue; + + let filterKey = key; + let operator = '='; // Default operator + const value = req.query[key]; + + console.log(`Processing filter key: "${key}" with value: "${value}"`); + + const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/); + if (operatorMatch) { + filterKey = operatorMatch[1]; + operator = operatorMatch[2]; + console.log(`Parsed filter key: "${filterKey}" with operator: "${operator}"`); + } + + // Special case for parentName requires join + const requiresJoin = filterKey === 'parentName'; + const columnInfo = getSafeColumnInfo(filterKey); + + if (columnInfo) { + console.log(`Column info for "${filterKey}":`, columnInfo); + const dbColumn = columnInfo.dbCol; + const valueType = columnInfo.type; + try { + let conditionFragment = ''; + let needsParam = true; + switch (operator.toLowerCase()) { + case 'eq': operator = '='; break; + case 'ne': operator = '<>'; break; + case 'gt': operator = '>'; break; + case 'gte': operator = '>='; break; + case 'lt': operator = '<'; break; + case 'lte': operator = '<='; break; + case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break; + case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break; + case 'between': + const [val1, val2] = String(value).split(','); + if (val1 !== undefined && val2 !== undefined) { + conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`; + params.push(parseValue(val1, valueType), parseValue(val2, valueType)); + needsParam = false; + } else continue; + break; + case 'in': + const inValues = String(value).split(','); + if (inValues.length > 0) { + const placeholders = inValues.map(() => `$${paramCounter++}`).join(', '); + conditionFragment = `${dbColumn} IN (${placeholders})`; + params.push(...inValues.map(v => parseValue(v, valueType))); + needsParam = false; + } else continue; + break; + default: operator = '='; break; + } + + if (needsParam) { + try { + // Special handling for categoryType to ensure it works + if (filterKey === 'categoryType') { + console.log(`Special handling for categoryType: ${value}`); + // Force conversion to integer + const numericValue = parseInt(value, 10); + if (!isNaN(numericValue)) { + console.log(`Successfully converted categoryType to integer: ${numericValue}`); + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; + params.push(numericValue); + } else { + console.error(`Failed to convert categoryType to integer: "${value}"`); + throw new Error(`Invalid categoryType value: "${value}"`); + } + } else { + // Normal handling for other fields + const parsedValue = parseValue(value, valueType); + console.log(`Parsed "${value}" as ${valueType}: ${parsedValue}`); + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; + params.push(parsedValue); + } + } catch (innerError) { + console.error(`Failed to parse "${value}" as ${valueType}:`, innerError); + throw innerError; + } + } else if (!conditionFragment) { // For LIKE/ILIKE where needsParam is false + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; // paramCounter was already incremented in push + } + + + if (conditionFragment) { + console.log(`Adding condition: ${conditionFragment}`); + conditions.push(`(${conditionFragment})`); + } + } catch (parseError) { + console.error(`Skipping filter for key "${key}" due to parsing error:`, parseError); + if (needsParam) paramCounter--; // Roll back counter if param push failed + } + } else { + console.warn(`Invalid filter key ignored: "${key}", not found in COLUMN_MAP`); + } + } + + // --- Execute Queries --- + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Need JOIN for parent_name if sorting/filtering by it, or always include for display + const sortColumn = sortColumnInfo?.dbCol; + + // Always include the category and parent joins for status and parent_name + const joinSql = ` + JOIN public.categories c ON cm.category_id = c.cat_id + LEFT JOIN public.categories p ON cm.parent_id = p.cat_id + `; + + const baseSql = ` + FROM public.category_metrics cm + ${joinSql} + ${whereClause} + `; + + const countSql = `SELECT COUNT(*) AS total ${baseSql}`; + const dataSql = ` + SELECT + cm.*, + c.status, + c.description, + p.name as parent_name, + p.type as parent_type + ${baseSql} + ${sortClause} + LIMIT $${paramCounter} OFFSET $${paramCounter + 1} + `; + const dataParams = [...params, limit, offset]; + + console.log("Count SQL:", countSql, params); + console.log("Data SQL:", dataSql, dataParams); + + const [countResult, dataResult] = await Promise.all([ + pool.query(countSql, params), + pool.query(dataSql, dataParams) + ]); + + const total = parseInt(countResult.rows[0].total, 10); + const categories = dataResult.rows.map(row => { + // Create a new object with both snake_case and camelCase keys + const transformedRow = { ...row }; // Start with original data + + for (const key in row) { + // Skip null/undefined values + if (row[key] === null || row[key] === undefined) { + continue; // Original already has the null value + } + + // Transform keys to match frontend expectations (add camelCase versions) + // First handle cases like sales_7d -> sales7d + let camelKey = key.replace(/_(\d+[a-z])/g, '$1'); + + // Then handle regular snake_case -> camelCase + camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + if (camelKey !== key) { // Only add if different from original + transformedRow[camelKey] = row[key]; + } + } + return transformedRow; + }); + + // --- Respond --- + res.json({ + categories, + pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit }, + }); + + } catch (error) { + console.error('Error fetching category metrics list:', error); + res.status(500).json({ error: 'Failed to fetch category metrics.' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/config.js b/inventory-server/src/routes/config.js new file mode 100644 index 0000000..5ccfba6 --- /dev/null +++ b/inventory-server/src/routes/config.js @@ -0,0 +1,325 @@ +const express = require('express'); +const router = express.Router(); + +// Debug middleware +router.use((req, res, next) => { + console.log(`[Config Route] ${req.method} ${req.path}`); + next(); +}); + +// ===== GLOBAL SETTINGS ===== + +// Get all global settings +router.get('/global', async (req, res) => { + const pool = req.app.locals.pool; + try { + console.log('[Config Route] Fetching global settings...'); + + const { rows } = await pool.query('SELECT * FROM settings_global ORDER BY setting_key'); + + console.log('[Config Route] Sending global settings:', rows); + res.json(rows); + } catch (error) { + console.error('[Config Route] Error fetching global settings:', error); + res.status(500).json({ error: 'Failed to fetch global settings', details: error.message }); + } +}); + +// Update global settings +router.put('/global', async (req, res) => { + const pool = req.app.locals.pool; + try { + console.log('[Config Route] Updating global settings:', req.body); + + // Validate request + if (!Array.isArray(req.body)) { + return res.status(400).json({ error: 'Request body must be an array of settings' }); + } + + // Begin transaction + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const setting of req.body) { + if (!setting.setting_key || !setting.setting_value) { + throw new Error('Each setting must have a key and value'); + } + + await client.query( + `UPDATE settings_global + SET setting_value = $1, + updated_at = CURRENT_TIMESTAMP + WHERE setting_key = $2`, + [setting.setting_value, setting.setting_key] + ); + } + + await client.query('COMMIT'); + res.json({ success: true }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + console.error('[Config Route] Error updating global settings:', error); + res.status(500).json({ error: 'Failed to update global settings', details: error.message }); + } +}); + +// ===== PRODUCT SETTINGS ===== + +// Get product settings with pagination and search +router.get('/products', async (req, res) => { + const pool = req.app.locals.pool; + try { + console.log('[Config Route] Fetching product settings...'); + + const page = parseInt(req.query.page) || 1; + const pageSize = parseInt(req.query.pageSize) || 10; + const offset = (page - 1) * pageSize; + const search = req.query.search || ''; + + // Get total count for pagination + const countQuery = search + ? `SELECT COUNT(*) FROM settings_product sp + JOIN products p ON sp.pid::text = p.pid::text + WHERE sp.pid::text ILIKE $1 OR p.title ILIKE $1` + : 'SELECT COUNT(*) FROM settings_product'; + + const countParams = search ? [`%${search}%`] : []; + const { rows: countResult } = await pool.query(countQuery, countParams); + const total = parseInt(countResult[0].count); + + // Get paginated settings + const query = search + ? `SELECT sp.*, p.title as product_name + FROM settings_product sp + JOIN products p ON sp.pid::text = p.pid::text + WHERE sp.pid::text ILIKE $1 OR p.title ILIKE $1 + ORDER BY sp.pid + LIMIT $2 OFFSET $3` + : `SELECT sp.*, p.title as product_name + FROM settings_product sp + JOIN products p ON sp.pid::text = p.pid::text + ORDER BY sp.pid + LIMIT $1 OFFSET $2`; + + const queryParams = search + ? [`%${search}%`, pageSize, offset] + : [pageSize, offset]; + + const { rows } = await pool.query(query, queryParams); + + const response = { + items: rows, + total, + page, + pageSize + }; + + console.log(`[Config Route] Sending ${rows.length} product settings`); + res.json(response); + } catch (error) { + console.error('[Config Route] Error fetching product settings:', error); + res.status(500).json({ error: 'Failed to fetch product settings', details: error.message }); + } +}); + +// Update product settings +router.put('/products/:pid', async (req, res) => { + const pool = req.app.locals.pool; + try { + const { pid } = req.params; + const { lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast } = req.body; + + console.log(`[Config Route] Updating product settings for ${pid}:`, req.body); + + // Check if product exists + const { rows: checkProduct } = await pool.query( + 'SELECT 1 FROM settings_product WHERE pid::text = $1', + [pid] + ); + + if (checkProduct.length === 0) { + // Insert if it doesn't exist + await pool.query( + `INSERT INTO settings_product + (pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast) + VALUES ($1, $2, $3, $4, $5, $6)`, + [pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] + ); + } else { + // Update if it exists + await pool.query( + `UPDATE settings_product + SET lead_time_days = $2, + days_of_stock = $3, + safety_stock = $4, + forecast_method = $5, + exclude_from_forecast = $6, + updated_at = CURRENT_TIMESTAMP + WHERE pid::text = $1`, + [pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] + ); + } + + res.json({ success: true }); + } catch (error) { + console.error(`[Config Route] Error updating product settings for ${req.params.pid}:`, error); + res.status(500).json({ error: 'Failed to update product settings', details: error.message }); + } +}); + +// Reset product settings to defaults +router.post('/products/:pid/reset', async (req, res) => { + const pool = req.app.locals.pool; + try { + const { pid } = req.params; + + console.log(`[Config Route] Resetting product settings for ${pid}`); + + // Reset by setting everything to null/default + await pool.query( + `UPDATE settings_product + SET lead_time_days = NULL, + days_of_stock = NULL, + safety_stock = 0, + forecast_method = NULL, + exclude_from_forecast = false, + updated_at = CURRENT_TIMESTAMP + WHERE pid::text = $1`, + [pid] + ); + + res.json({ success: true }); + } catch (error) { + console.error(`[Config Route] Error resetting product settings for ${req.params.pid}:`, error); + res.status(500).json({ error: 'Failed to reset product settings', details: error.message }); + } +}); + +// ===== VENDOR SETTINGS ===== + +// Get vendor settings with pagination and search +router.get('/vendors', async (req, res) => { + const pool = req.app.locals.pool; + try { + console.log('[Config Route] Fetching vendor settings...'); + + const page = parseInt(req.query.page) || 1; + const pageSize = parseInt(req.query.pageSize) || 10; + const offset = (page - 1) * pageSize; + const search = req.query.search || ''; + + // Get total count for pagination + const countQuery = search + ? 'SELECT COUNT(*) FROM settings_vendor WHERE vendor ILIKE $1' + : 'SELECT COUNT(*) FROM settings_vendor'; + + const countParams = search ? [`%${search}%`] : []; + const { rows: countResult } = await pool.query(countQuery, countParams); + const total = parseInt(countResult[0].count); + + // Get paginated settings + const query = search + ? `SELECT * FROM settings_vendor + WHERE vendor ILIKE $1 + ORDER BY vendor + LIMIT $2 OFFSET $3` + : `SELECT * FROM settings_vendor + ORDER BY vendor + LIMIT $1 OFFSET $2`; + + const queryParams = search + ? [`%${search}%`, pageSize, offset] + : [pageSize, offset]; + + const { rows } = await pool.query(query, queryParams); + + const response = { + items: rows, + total, + page, + pageSize + }; + + console.log(`[Config Route] Sending ${rows.length} vendor settings`); + res.json(response); + } catch (error) { + console.error('[Config Route] Error fetching vendor settings:', error); + res.status(500).json({ error: 'Failed to fetch vendor settings', details: error.message }); + } +}); + +// Update vendor settings +router.put('/vendors/:vendor', async (req, res) => { + const pool = req.app.locals.pool; + try { + const vendor = req.params.vendor; + const { default_lead_time_days, default_days_of_stock } = req.body; + + console.log(`[Config Route] Updating vendor settings for ${vendor}:`, req.body); + + // Check if vendor exists + const { rows: checkVendor } = await pool.query( + 'SELECT 1 FROM settings_vendor WHERE vendor = $1', + [vendor] + ); + + if (checkVendor.length === 0) { + // Insert if it doesn't exist + await pool.query( + `INSERT INTO settings_vendor + (vendor, default_lead_time_days, default_days_of_stock) + VALUES ($1, $2, $3)`, + [vendor, default_lead_time_days, default_days_of_stock] + ); + } else { + // Update if it exists + await pool.query( + `UPDATE settings_vendor + SET default_lead_time_days = $2, + default_days_of_stock = $3, + updated_at = CURRENT_TIMESTAMP + WHERE vendor = $1`, + [vendor, default_lead_time_days, default_days_of_stock] + ); + } + + res.json({ success: true }); + } catch (error) { + console.error(`[Config Route] Error updating vendor settings for ${req.params.vendor}:`, error); + res.status(500).json({ error: 'Failed to update vendor settings', details: error.message }); + } +}); + +// Reset vendor settings to defaults +router.post('/vendors/:vendor/reset', async (req, res) => { + const pool = req.app.locals.pool; + try { + const vendor = req.params.vendor; + + console.log(`[Config Route] Resetting vendor settings for ${vendor}`); + + // Reset by setting everything to null + await pool.query( + `UPDATE settings_vendor + SET default_lead_time_days = NULL, + default_days_of_stock = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE vendor = $1`, + [vendor] + ); + + res.json({ success: true }); + } catch (error) { + console.error(`[Config Route] Error resetting vendor settings for ${req.params.vendor}:`, error); + res.status(500).json({ error: 'Failed to reset vendor settings', details: error.message }); + } +}); + +// Export the router +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 0519ecb..3a21c19 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -1 +1,1339 @@ - \ No newline at end of file +const express = require('express'); +const router = express.Router(); +const db = require('../utils/db'); + +// Import status codes +const { ReceivingStatus } = require('../types/status-codes'); + +// Helper function to execute queries using the connection pool +async function executeQuery(sql, params = []) { + const pool = db.getPool(); + if (!pool) { + throw new Error('Database pool not initialized'); + } + return pool.query(sql, params); +} + +// GET /dashboard/stock/metrics +// Returns brand-level stock metrics +router.get('/stock/metrics', async (req, res) => { + try { + // Get stock metrics + const { rows: [stockMetrics] } = await executeQuery(` + SELECT + COALESCE(COUNT(*), 0)::integer as total_products, + COALESCE(COUNT(CASE WHEN current_stock > 0 THEN 1 END), 0)::integer as products_in_stock, + COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock END), 0)::integer as total_units, + ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_cost END), 0)::numeric, 3) as total_cost, + ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_retail END), 0)::numeric, 3) as total_retail + FROM product_metrics + `); + + console.log('Raw stockMetrics from database:', stockMetrics); + console.log('stockMetrics.total_products:', stockMetrics.total_products); + console.log('stockMetrics.products_in_stock:', stockMetrics.products_in_stock); + console.log('stockMetrics.total_units:', stockMetrics.total_units); + console.log('stockMetrics.total_cost:', stockMetrics.total_cost); + console.log('stockMetrics.total_retail:', stockMetrics.total_retail); + + // Get brand stock values with Other category + const { rows: brandValues } = await executeQuery(` + WITH brand_totals AS ( + SELECT + COALESCE(brand, 'Unbranded') as brand, + COUNT(DISTINCT pid)::integer as variant_count, + COALESCE(SUM(current_stock), 0)::integer as stock_units, + ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) as stock_cost, + ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 3) as stock_retail + FROM product_metrics + WHERE current_stock > 0 + GROUP BY COALESCE(brand, 'Unbranded') + HAVING ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) > 0 + ), + other_brands AS ( + SELECT + 'Other' as brand, + SUM(variant_count)::integer as variant_count, + SUM(stock_units)::integer as stock_units, + ROUND(SUM(stock_cost)::numeric, 3) as stock_cost, + ROUND(SUM(stock_retail)::numeric, 3) as stock_retail + FROM brand_totals + WHERE stock_cost <= 5000 + ), + main_brands AS ( + SELECT * + FROM brand_totals + WHERE stock_cost > 5000 + ), + combined_results AS ( + SELECT * FROM main_brands + UNION ALL + SELECT * FROM other_brands + WHERE stock_cost > 0 + ) + SELECT * FROM combined_results + ORDER BY CASE WHEN brand = 'Other' THEN 1 ELSE 0 END, stock_cost DESC + `); + + // Format the response with explicit type conversion + const response = { + totalProducts: parseInt(stockMetrics.total_products) || 0, + productsInStock: parseInt(stockMetrics.products_in_stock) || 0, + totalStockUnits: parseInt(stockMetrics.total_units) || 0, + totalStockCost: parseFloat(stockMetrics.total_cost) || 0, + totalStockRetail: parseFloat(stockMetrics.total_retail) || 0, + brandStock: brandValues.map(v => ({ + brand: v.brand, + variants: parseInt(v.variant_count) || 0, + units: parseInt(v.stock_units) || 0, + cost: parseFloat(v.stock_cost) || 0, + retail: parseFloat(v.stock_retail) || 0 + })) + }; + + res.json(response); + } catch (err) { + console.error('Error fetching stock metrics:', err); + res.status(500).json({ error: 'Failed to fetch stock metrics' }); + } +}); + +// GET /dashboard/purchase/metrics +// Returns purchase order metrics by vendor +router.get('/purchase/metrics', async (req, res) => { + try { + // First check if there are any purchase orders in the database + const { rows: [poCount] } = await executeQuery(` + SELECT COUNT(*) as count FROM purchase_orders + `); + + const { rows: [poMetrics] } = await executeQuery(` + WITH po_metrics AS ( + SELECT + po_id, + status, + date, + expected_date, + pid, + ordered, + po_cost_price + FROM purchase_orders po + WHERE po.status NOT IN ('canceled', 'done') + AND po.date >= CURRENT_DATE - INTERVAL '6 months' + ) + SELECT + COUNT(DISTINCT po_id)::integer as active_pos, + COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer as overdue_pos, + SUM(ordered)::integer as total_units, + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost, + ROUND(SUM(ordered * pm.current_price)::numeric, 3) as total_retail + FROM po_metrics po + JOIN product_metrics pm ON po.pid = pm.pid + `); + + const { rows: vendorOrders } = await executeQuery(` + WITH po_by_vendor AS ( + SELECT + vendor, + po_id, + SUM(ordered) as total_ordered, + SUM(ordered * po_cost_price) as total_cost + FROM purchase_orders + WHERE status NOT IN ('canceled', 'done') + AND date >= CURRENT_DATE - INTERVAL '6 months' + GROUP BY vendor, po_id + ) + SELECT + pv.vendor, + COUNT(DISTINCT pv.po_id)::integer as orders, + SUM(pv.total_ordered)::integer as units, + ROUND(SUM(pv.total_cost)::numeric, 3) as cost, + ROUND(SUM(pv.total_ordered * pm.current_price)::numeric, 3) as retail + FROM po_by_vendor pv + JOIN purchase_orders po ON pv.po_id = po.po_id + JOIN product_metrics pm ON po.pid = pm.pid + GROUP BY pv.vendor + HAVING ROUND(SUM(pv.total_cost)::numeric, 3) > 0 + ORDER BY cost DESC + `); + + // If no purchase orders exist at all in the database, return dummy data + if (parseInt(poCount.count) === 0) { + console.log('No purchase orders found in database, returning dummy data'); + + return res.json({ + activePurchaseOrders: 12, + overduePurchaseOrders: 3, + onOrderUnits: 1250, + onOrderCost: 12500, + onOrderRetail: 25000, + vendorOrders: [ + { vendor: "Test Vendor 1", orders: 5, units: 500, cost: 5000, retail: 10000 }, + { vendor: "Test Vendor 2", orders: 4, units: 400, cost: 4000, retail: 8000 }, + { vendor: "Test Vendor 3", orders: 3, units: 350, cost: 3500, retail: 7000 } + ] + }); + } + + // If no active purchase orders match the criteria, return zeros instead of dummy data + if (vendorOrders.length === 0) { + console.log('No active purchase orders matching criteria, returning zeros'); + + return res.json({ + activePurchaseOrders: parseInt(poMetrics.active_pos) || 0, + overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0, + onOrderUnits: parseInt(poMetrics.total_units) || 0, + onOrderCost: parseFloat(poMetrics.total_cost) || 0, + onOrderRetail: parseFloat(poMetrics.total_retail) || 0, + vendorOrders: [] + }); + } + + // Format response to match PurchaseMetricsData interface + const response = { + activePurchaseOrders: parseInt(poMetrics.active_pos) || 0, + overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0, + onOrderUnits: parseInt(poMetrics.total_units) || 0, + onOrderCost: parseFloat(poMetrics.total_cost) || 0, + onOrderRetail: parseFloat(poMetrics.total_retail) || 0, + vendorOrders: vendorOrders.map(v => ({ + vendor: v.vendor, + orders: parseInt(v.orders) || 0, + units: parseInt(v.units) || 0, + cost: parseFloat(v.cost) || 0, + retail: parseFloat(v.retail) || 0 + })) + }; + + res.json(response); + } catch (err) { + console.error('Error fetching purchase metrics:', err); + res.status(500).json({ + error: 'Failed to fetch purchase metrics', + details: err.message, + activePurchaseOrders: 0, + overduePurchaseOrders: 0, + onOrderUnits: 0, + onOrderCost: 0, + onOrderRetail: 0, + vendorOrders: [] + }); + } +}); + +// GET /dashboard/replenishment/metrics +// Returns replenishment needs by category +router.get('/replenishment/metrics', async (req, res) => { + try { + // Get summary metrics + const { rows: [metrics] } = await executeQuery(` + SELECT + COUNT(DISTINCT pm.pid)::integer as products_to_replenish, + COALESCE(SUM(pm.replenishment_units), 0)::integer as total_units_needed, + ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 3) as total_cost, + ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 3) as total_retail + FROM product_metrics pm + WHERE pm.is_replenishable = true + AND (pm.status IN ('Critical', 'Reorder') + OR pm.current_stock < 0) + AND pm.replenishment_units > 0 + `); + + // Get top variants to replenish + const { rows: variants } = await executeQuery(` + SELECT + pm.pid, + pm.title, + pm.current_stock::integer as current_stock, + pm.replenishment_units::integer as replenish_qty, + ROUND(pm.replenishment_cost::numeric, 3) as replenish_cost, + ROUND(pm.replenishment_retail::numeric, 3) as replenish_retail, + pm.status, + pm.planning_period_days::text as planning_period + FROM product_metrics pm + WHERE pm.is_replenishable = true + AND (pm.status IN ('Critical', 'Reorder') + OR pm.current_stock < 0) + AND pm.replenishment_units > 0 + ORDER BY + CASE pm.status + WHEN 'Critical' THEN 1 + WHEN 'Reorder' THEN 2 + END, + replenish_cost DESC + LIMIT 5 + `); + + // If no data, provide dummy data + if (!metrics || variants.length === 0) { + console.log('No replenishment metrics found in new schema, returning dummy data'); + + return res.json({ + productsToReplenish: 15, + unitsToReplenish: 1500, + replenishmentCost: 15000.00, + replenishmentRetail: 30000.00, + topVariants: [ + { id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" }, + { id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" }, + { id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" }, + { id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" }, + { id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" } + ] + }); + } + + // Format response + const response = { + productsToReplenish: parseInt(metrics.products_to_replenish) || 0, + unitsToReplenish: parseInt(metrics.total_units_needed) || 0, + replenishmentCost: parseFloat(metrics.total_cost) || 0, + replenishmentRetail: parseFloat(metrics.total_retail) || 0, + topVariants: variants.map(v => ({ + id: v.pid, + title: v.title, + currentStock: parseInt(v.current_stock) || 0, + replenishQty: parseInt(v.replenish_qty) || 0, + replenishCost: parseFloat(v.replenish_cost) || 0, + replenishRetail: parseFloat(v.replenish_retail) || 0, + status: v.status, + planningPeriod: v.planning_period + })) + }; + + res.json(response); + } catch (err) { + console.error('Error fetching replenishment metrics:', err); + + // Return dummy data on error + res.json({ + productsToReplenish: 15, + unitsToReplenish: 1500, + replenishmentCost: 15000.00, + replenishmentRetail: 30000.00, + topVariants: [ + { id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" }, + { id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" }, + { id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" }, + { id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" }, + { id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" } + ] + }); + } +}); + +// GET /dashboard/forecast/metrics +// Returns sales forecasts for specified period +router.get('/forecast/metrics', async (req, res) => { + // Default to last 30 days if no date range provided + const today = new Date(); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 30); + + const startDate = req.query.startDate || thirtyDaysAgo.toISOString(); + const endDate = req.query.endDate || today.toISOString(); + + try { + // Check if sales_forecasts table exists and has data + const { rows: tableCheck } = await executeQuery(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'sales_forecasts' + ) as table_exists + `); + + const tableExists = tableCheck[0].table_exists; + + if (!tableExists) { + console.log('sales_forecasts table does not exist, returning dummy data'); + + // Generate dummy data for forecast + const days = 30; + const dummyData = []; + const startDateObj = new Date(startDate); + + for (let i = 0; i < days; i++) { + const currentDate = new Date(startDateObj); + currentDate.setDate(startDateObj.getDate() + i); + + // Use sales data with slight randomization + const baseValue = 500 + Math.random() * 200; + dummyData.push({ + date: currentDate.toISOString().split('T')[0], + revenue: parseFloat((baseValue + Math.random() * 100).toFixed(2)), + confidence: parseFloat((0.7 + Math.random() * 0.2).toFixed(2)) + }); + } + + // Return dummy response + const response = { + forecastSales: 500, + forecastRevenue: 25000, + confidenceLevel: 0.85, + dailyForecasts: dummyData, + categoryForecasts: [ + { category: "Electronics", units: 120, revenue: 6000, confidence: 0.9 }, + { category: "Clothing", units: 80, revenue: 4000, confidence: 0.8 }, + { category: "Home Goods", units: 150, revenue: 7500, confidence: 0.75 }, + { category: "Others", units: 150, revenue: 7500, confidence: 0.7 } + ] + }; + + return res.json(response); + } + + // If the table exists, try to query it with proper error handling + try { + // Get summary metrics + const { rows: metrics } = await executeQuery(` + SELECT + COALESCE(SUM(forecast_units), 0) as total_forecast_units, + COALESCE(SUM(forecast_revenue), 0) as total_forecast_revenue, + COALESCE(AVG(confidence_level), 0) as overall_confidence + FROM sales_forecasts + WHERE forecast_date BETWEEN $1 AND $2 + `, [startDate, endDate]); + + // Get daily forecasts + const { rows: dailyForecasts } = await executeQuery(` + SELECT + DATE(forecast_date) as date, + COALESCE(SUM(forecast_revenue), 0) as revenue, + COALESCE(AVG(confidence_level), 0) as confidence + FROM sales_forecasts + WHERE forecast_date BETWEEN $1 AND $2 + GROUP BY DATE(forecast_date) + ORDER BY date + `, [startDate, endDate]); + + // Get category forecasts + const { rows: categoryForecasts } = await executeQuery(` + SELECT + c.name as category, + COALESCE(SUM(cf.forecast_units), 0) as units, + COALESCE(SUM(cf.forecast_revenue), 0) as revenue, + COALESCE(AVG(cf.confidence_level), 0) as confidence + FROM category_forecasts cf + JOIN categories c ON cf.category_id = c.cat_id + WHERE cf.forecast_date BETWEEN $1 AND $2 + GROUP BY c.cat_id, c.name + ORDER BY revenue DESC + `, [startDate, endDate]); + + // Format response + const response = { + forecastSales: parseInt(metrics[0]?.total_forecast_units) || 0, + forecastRevenue: parseFloat(metrics[0]?.total_forecast_revenue) || 0, + confidenceLevel: parseFloat(metrics[0]?.overall_confidence) || 0, + dailyForecasts: dailyForecasts.map(d => ({ + date: d.date, + revenue: parseFloat(d.revenue) || 0, + confidence: parseFloat(d.confidence) || 0 + })), + categoryForecasts: categoryForecasts.map(c => ({ + category: c.category, + units: parseInt(c.units) || 0, + revenue: parseFloat(c.revenue) || 0, + confidence: parseFloat(c.confidence) || 0 + })) + }; + + res.json(response); + } catch (err) { + console.error('Error with forecast tables structure, returning dummy data:', err); + + // Generate dummy data for forecast as fallback + const days = 30; + const dummyData = []; + const startDateObj = new Date(startDate); + + for (let i = 0; i < days; i++) { + const currentDate = new Date(startDateObj); + currentDate.setDate(startDateObj.getDate() + i); + + const baseValue = 500 + Math.random() * 200; + dummyData.push({ + date: currentDate.toISOString().split('T')[0], + revenue: parseFloat((baseValue + Math.random() * 100).toFixed(2)), + confidence: parseFloat((0.7 + Math.random() * 0.2).toFixed(2)) + }); + } + + // Return dummy response + const response = { + forecastSales: 500, + forecastRevenue: 25000, + confidenceLevel: 0.85, + dailyForecasts: dummyData, + categoryForecasts: [ + { category: "Electronics", units: 120, revenue: 6000, confidence: 0.9 }, + { category: "Clothing", units: 80, revenue: 4000, confidence: 0.8 }, + { category: "Home Goods", units: 150, revenue: 7500, confidence: 0.75 }, + { category: "Others", units: 150, revenue: 7500, confidence: 0.7 } + ] + }; + + res.json(response); + } + } catch (err) { + console.error('Error fetching forecast metrics:', err); + res.status(500).json({ error: 'Failed to fetch forecast metrics' }); + } +}); + +// GET /dashboard/overstock/metrics +// Returns overstock metrics by category +router.get('/overstock/metrics', async (req, res) => { + try { + // Check if we have any products with Overstock status + const { rows: [countCheck] } = await executeQuery(` + SELECT COUNT(*) as overstock_count FROM product_metrics WHERE status = 'Overstock' + `); + + console.log('Overstock count:', countCheck.overstock_count); + + // If no overstock products, return empty metrics + if (parseInt(countCheck.overstock_count) === 0) { + return res.json({ + overstockedProducts: 0, + total_excess_units: 0, + total_excess_cost: 0, + total_excess_retail: 0, + category_data: [] + }); + } + + // Get summary metrics in a simpler, more direct query + const { rows: [summaryMetrics] } = await executeQuery(` + SELECT + COUNT(DISTINCT pid)::integer as total_overstocked, + SUM(overstocked_units)::integer as total_excess_units, + ROUND(SUM(overstocked_cost)::numeric, 3) as total_excess_cost, + ROUND(SUM(overstocked_retail)::numeric, 3) as total_excess_retail + FROM product_metrics + WHERE status = 'Overstock' + `); + + // Get category breakdowns separately + const { rows: categoryData } = await executeQuery(` + SELECT + c.name as category_name, + COUNT(DISTINCT pm.pid)::integer as overstocked_products, + SUM(pm.overstocked_units)::integer as total_excess_units, + ROUND(SUM(pm.overstocked_cost)::numeric, 3) as total_excess_cost, + ROUND(SUM(pm.overstocked_retail)::numeric, 3) as total_excess_retail + FROM categories c + JOIN product_categories pc ON c.cat_id = pc.cat_id + JOIN product_metrics pm ON pc.pid = pm.pid + WHERE pm.status = 'Overstock' + GROUP BY c.name + ORDER BY total_excess_cost DESC + LIMIT 8 + `); + + console.log('Summary metrics:', summaryMetrics); + console.log('Category data count:', categoryData.length); + + // Format response with explicit type conversion + const response = { + overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0, + total_excess_units: parseInt(summaryMetrics.total_excess_units) || 0, + total_excess_cost: parseFloat(summaryMetrics.total_excess_cost) || 0, + total_excess_retail: parseFloat(summaryMetrics.total_excess_retail) || 0, + category_data: categoryData.map(cat => ({ + category: cat.category_name, + products: parseInt(cat.overstocked_products) || 0, + units: parseInt(cat.total_excess_units) || 0, + cost: parseFloat(cat.total_excess_cost) || 0, + retail: parseFloat(cat.total_excess_retail) || 0 + })) + }; + + res.json(response); + } catch (err) { + console.error('Error fetching overstock metrics:', err); + + // Return dummy data on error + res.json({ + overstockedProducts: 10, + total_excess_units: 500, + total_excess_cost: 5000, + total_excess_retail: 10000, + category_data: [ + { category: "Electronics", products: 3, units: 150, cost: 1500, retail: 3000 }, + { category: "Clothing", products: 4, units: 200, cost: 2000, retail: 4000 }, + { category: "Home Goods", products: 2, units: 100, cost: 1000, retail: 2000 }, + { category: "Office Supplies", products: 1, units: 50, cost: 500, retail: 1000 } + ] + }); + } +}); + +// GET /dashboard/overstock/products +// Returns list of most overstocked products +router.get('/overstock/products', async (req, res) => { + const limit = parseInt(req.query.limit) || 50; + try { + const { rows } = await executeQuery(` + SELECT + pm.pid, + pm.sku AS SKU, + pm.title, + pm.brand, + pm.vendor, + pm.current_stock as stock_quantity, + pm.current_cost_price as cost_price, + pm.current_price as price, + pm.sales_velocity_daily as daily_sales_avg, + pm.stock_cover_in_days as days_of_inventory, + pm.overstocked_units, + pm.overstocked_cost as excess_cost, + pm.overstocked_retail as excess_retail, + STRING_AGG(c.name, ', ') as categories + FROM product_metrics pm + LEFT JOIN product_categories pc ON pm.pid = pc.pid + LEFT JOIN categories c ON pc.cat_id = c.cat_id + WHERE pm.status = 'Overstock' + GROUP BY pm.pid, pm.sku, pm.title, pm.brand, pm.vendor, pm.current_stock, pm.current_cost_price, pm.current_price, + pm.sales_velocity_daily, pm.stock_cover_in_days, pm.overstocked_units, pm.overstocked_cost, pm.overstocked_retail + ORDER BY excess_cost DESC + LIMIT $1 + `, [limit]); + res.json(rows); + } catch (err) { + console.error('Error fetching overstocked products:', err); + res.status(500).json({ error: 'Failed to fetch overstocked products' }); + } +}); + +// GET /dashboard/best-sellers +// Returns best-selling products, vendors, and categories +router.get('/best-sellers', async (req, res) => { + try { + // Common CTE for category paths + const categoryPathCTE = ` + WITH RECURSIVE category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ) + `; + + // Get best selling products + const { rows: products } = await executeQuery(` + SELECT + p.pid, + p.SKU as sku, + p.title, + SUM(o.quantity) as units_sold, + ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, + ROUND(SUM(o.price * o.quantity - p.cost_price * o.quantity)::numeric, 3) as profit + FROM products p + JOIN orders o ON p.pid = o.pid + WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' + AND o.canceled = false + GROUP BY p.pid, p.SKU, p.title + ORDER BY units_sold DESC + LIMIT 10 + `); + + // Get best selling brands + const { rows: brands } = await executeQuery(` + SELECT + p.brand, + SUM(o.quantity) as units_sold, + ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, + ROUND(SUM(o.price * o.quantity - p.cost_price * o.quantity)::numeric, 3) as profit, + ROUND( + ((SUM(CASE + WHEN o.date >= CURRENT_DATE - INTERVAL '30 days' + THEN o.price * o.quantity + ELSE 0 + END) / + NULLIF(SUM(CASE + WHEN o.date >= CURRENT_DATE - INTERVAL '60 days' + AND o.date < CURRENT_DATE - INTERVAL '30 days' + THEN o.price * o.quantity + ELSE 0 + END), 0)) - 1) * 100, + 1 + ) as growth_rate + FROM products p + JOIN orders o ON p.pid = o.pid + WHERE o.date >= CURRENT_DATE - INTERVAL '60 days' + AND o.canceled = false + GROUP BY p.brand + ORDER BY units_sold DESC + LIMIT 10 + `); + + // Get best selling categories with full path + const { rows: categories } = await executeQuery(` + ${categoryPathCTE} + SELECT + c.cat_id, + c.name, + cp.path as categoryPath, + SUM(o.quantity) as units_sold, + ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, + ROUND(SUM(o.price * o.quantity - p.cost_price * o.quantity)::numeric, 3) as profit, + ROUND( + ((SUM(CASE + WHEN o.date >= CURRENT_DATE - INTERVAL '30 days' + THEN o.price * o.quantity + ELSE 0 + END) / + NULLIF(SUM(CASE + WHEN o.date >= CURRENT_DATE - INTERVAL '60 days' + AND o.date < CURRENT_DATE - INTERVAL '30 days' + THEN o.price * o.quantity + ELSE 0 + END), 0)) - 1) * 100, + 1 + ) as growth_rate + FROM products p + JOIN orders o ON p.pid = o.pid + JOIN product_categories pc ON p.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + JOIN category_path cp ON c.cat_id = cp.cat_id + WHERE o.date >= CURRENT_DATE - INTERVAL '60 days' + AND o.canceled = false + GROUP BY c.cat_id, c.name, cp.path + ORDER BY units_sold DESC + LIMIT 10 + `); + + // If there's no data, provide some test data + if (products.length === 0 && brands.length === 0 && categories.length === 0) { + console.log('No best sellers data found, returning dummy data'); + + return res.json({ + products: [ + {pid: 1, sku: 'TEST001', title: 'Test Product 1', units_sold: 100, revenue: '1000.00', profit: '400.00'}, + {pid: 2, sku: 'TEST002', title: 'Test Product 2', units_sold: 90, revenue: '900.00', profit: '360.00'}, + {pid: 3, sku: 'TEST003', title: 'Test Product 3', units_sold: 80, revenue: '800.00', profit: '320.00'}, + ], + brands: [ + {brand: 'Test Brand 1', units_sold: 200, revenue: '2000.00', profit: '800.00', growth_rate: '10.5'}, + {brand: 'Test Brand 2', units_sold: 150, revenue: '1500.00', profit: '600.00', growth_rate: '5.2'}, + ], + categories: [ + {cat_id: 1, name: 'Test Category 1', categoryPath: 'Test Category 1', units_sold: 150, revenue: '1500.00', profit: '600.00', growth_rate: '8.5'}, + {cat_id: 2, name: 'Test Category 2', categoryPath: 'Parent Category > Test Category 2', units_sold: 100, revenue: '1000.00', profit: '400.00', growth_rate: '4.2'}, + ] + }); + } + + res.json({ products, brands, categories }); + } catch (err) { + console.error('Error fetching best sellers:', err); + res.status(500).json({ + error: 'Failed to fetch best sellers', + // Return dummy data on error + products: [ + {pid: 1, sku: 'TEST001', title: 'Test Product 1', units_sold: 100, revenue: '1000.00', profit: '400.00'}, + {pid: 2, sku: 'TEST002', title: 'Test Product 2', units_sold: 90, revenue: '900.00', profit: '360.00'}, + {pid: 3, sku: 'TEST003', title: 'Test Product 3', units_sold: 80, revenue: '800.00', profit: '320.00'}, + ], + brands: [ + {brand: 'Test Brand 1', units_sold: 200, revenue: '2000.00', profit: '800.00', growth_rate: '10.5'}, + {brand: 'Test Brand 2', units_sold: 150, revenue: '1500.00', profit: '600.00', growth_rate: '5.2'}, + ], + categories: [ + {cat_id: 1, name: 'Test Category 1', categoryPath: 'Test Category 1', units_sold: 150, revenue: '1500.00', profit: '600.00', growth_rate: '8.5'}, + {cat_id: 2, name: 'Test Category 2', categoryPath: 'Parent Category > Test Category 2', units_sold: 100, revenue: '1000.00', profit: '400.00', growth_rate: '4.2'}, + ] + }); + } +}); + +// GET /dashboard/sales/metrics +// Returns sales metrics for specified period +router.get('/sales/metrics', async (req, res) => { + // Default to last 30 days if no date range provided + const today = new Date(); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 30); + + const startDate = req.query.startDate || thirtyDaysAgo.toISOString(); + const endDate = req.query.endDate || today.toISOString(); + + try { + // Get daily orders and totals for the specified period + const { rows: dailyRows } = await executeQuery(` + SELECT + DATE(date) as sale_date, + COUNT(DISTINCT order_number) as total_orders, + SUM(quantity) as total_units, + SUM(price * quantity) as total_revenue, + SUM(costeach * quantity) as total_cogs + FROM orders + WHERE date BETWEEN $1 AND $2 + AND canceled = false + GROUP BY DATE(date) + ORDER BY sale_date + `, [startDate, endDate]); + + // Get overall metrics for the period + const { rows: [metrics] } = await executeQuery(` + SELECT + COUNT(DISTINCT order_number) as total_orders, + SUM(quantity) as total_units, + SUM(price * quantity) as total_revenue, + SUM(costeach * quantity) as total_cogs + FROM orders + WHERE date BETWEEN $1 AND $2 + AND canceled = false + `, [startDate, endDate]); + + const response = { + totalOrders: parseInt(metrics?.total_orders) || 0, + totalUnitsSold: parseInt(metrics?.total_units) || 0, + totalCogs: parseFloat(metrics?.total_cogs) || 0, + totalRevenue: parseFloat(metrics?.total_revenue) || 0, + dailySales: dailyRows.map(day => ({ + date: day.sale_date, + units: parseInt(day.total_units) || 0, + revenue: parseFloat(day.total_revenue) || 0, + cogs: parseFloat(day.total_cogs) || 0 + })) + }; + + res.json(response); + } catch (err) { + console.error('Error fetching sales metrics:', err); + res.status(500).json({ error: 'Failed to fetch sales metrics' }); + } +}); + +// GET /dashboard/low-stock/products +// Returns list of products with critical or low stock levels +router.get('/low-stock/products', async (req, res) => { + const limit = parseInt(req.query.limit) || 50; + try { + const { rows } = await executeQuery(` + SELECT + p.pid, + p.SKU, + p.title, + p.brand, + p.vendor, + p.stock_quantity, + p.cost_price, + p.price, + pm.daily_sales_avg, + pm.days_of_inventory, + pm.reorder_qty, + (pm.reorder_qty * p.cost_price) as reorder_cost, + STRING_AGG(c.name, ', ') as categories, + pm.lead_time_status + FROM products p + JOIN product_metrics pm ON p.pid = pm.pid + LEFT JOIN product_categories pc ON p.pid = pc.pid + LEFT JOIN categories c ON pc.cat_id = c.cat_id + WHERE pm.stock_status IN ('Critical', 'Reorder') + AND p.replenishable = true + GROUP BY p.pid, pm.daily_sales_avg, pm.days_of_inventory, pm.reorder_qty, pm.lead_time_status + ORDER BY + CASE pm.stock_status + WHEN 'Critical' THEN 1 + WHEN 'Reorder' THEN 2 + END, + pm.days_of_inventory ASC + LIMIT $1 + `, [limit]); + res.json(rows); + } catch (err) { + console.error('Error fetching low stock products:', err); + res.status(500).json({ error: 'Failed to fetch low stock products' }); + } +}); + +// GET /dashboard/trending/products +// Returns list of trending products based on recent sales velocity +router.get('/trending/products', async (req, res) => { + const days = parseInt(req.query.days) || 30; + const limit = parseInt(req.query.limit) || 20; + try { + const { rows } = await executeQuery(` + WITH recent_sales AS ( + SELECT + o.pid, + COUNT(DISTINCT o.order_number) as recent_orders, + SUM(o.quantity) as recent_units, + SUM(o.price * o.quantity) as recent_revenue + FROM orders o + WHERE o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '${days} days' + GROUP BY o.pid + ) + SELECT + p.pid, + p.SKU, + p.title, + p.brand, + p.vendor, + p.stock_quantity, + rs.recent_orders, + rs.recent_units, + rs.recent_revenue, + pm.daily_sales_avg, + pm.stock_status, + (rs.recent_units::float / ${days}) as daily_velocity, + ((rs.recent_units::float / ${days}) - pm.daily_sales_avg) / NULLIF(pm.daily_sales_avg, 0) * 100 as velocity_change, + STRING_AGG(c.name, ', ') as categories + FROM recent_sales rs + JOIN products p ON rs.pid = p.pid + JOIN product_metrics pm ON p.pid = pm.pid + LEFT JOIN product_categories pc ON p.pid = pc.pid + LEFT JOIN categories c ON pc.cat_id = c.cat_id + GROUP BY p.pid, p.SKU, p.title, p.brand, p.vendor, p.stock_quantity, rs.recent_orders, rs.recent_units, rs.recent_revenue, pm.daily_sales_avg, pm.stock_status + HAVING ((rs.recent_units::float / ${days}) - pm.daily_sales_avg) / NULLIF(pm.daily_sales_avg, 0) * 100 > 0 + ORDER BY velocity_change DESC + LIMIT $1 + `, [limit]); + res.json(rows); + } catch (err) { + console.error('Error fetching trending products:', err); + res.status(500).json({ error: 'Failed to fetch trending products' }); + } +}); + +// GET /dashboard/vendor/performance +// Returns detailed vendor performance metrics +router.get('/vendor/performance', async (req, res) => { + console.log('Vendor performance API called'); + try { + // Set cache control headers to prevent 304 + res.set({ + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }); + + // First check if the purchase_orders table has data + const { rows: tableCheck } = await executeQuery(` + SELECT COUNT(*) as count FROM purchase_orders + `); + + console.log('Purchase orders count:', tableCheck[0].count); + + // If no purchase orders, return dummy data - never return empty array + if (parseInt(tableCheck[0].count) === 0) { + console.log('No purchase orders found, returning dummy data'); + return res.json([ + { + vendor: "Example Vendor 1", + total_orders: 12, + avg_lead_time: 7.5, + on_time_delivery_rate: 92.5, + avg_fill_rate: 97.0, + active_orders: 3, + overdue_orders: 0 + }, + { + vendor: "Example Vendor 2", + total_orders: 8, + avg_lead_time: 10.2, + on_time_delivery_rate: 87.5, + avg_fill_rate: 95.5, + active_orders: 2, + overdue_orders: 1 + }, + { + vendor: "Example Vendor 3", + total_orders: 5, + avg_lead_time: 15.0, + on_time_delivery_rate: 80.0, + avg_fill_rate: 92.0, + active_orders: 1, + overdue_orders: 0 + } + ]); + } + + const query = ` + WITH vendor_orders AS ( + SELECT + po.vendor, + COUNT(DISTINCT po.po_id)::integer as total_orders, + COALESCE(ROUND(AVG(CASE WHEN po.received_date IS NOT NULL + THEN EXTRACT(EPOCH FROM (po.received_date - po.date))/86400 + ELSE NULL END)::numeric, 2), 0) as avg_lead_time, + COALESCE(ROUND(SUM(CASE + WHEN po.status = 'done' AND po.received_date <= po.expected_date + THEN 1 + ELSE 0 + END)::numeric * 100.0 / NULLIF(COUNT(*)::numeric, 0), 2), 0) as on_time_delivery_rate, + COALESCE(ROUND(AVG(CASE + WHEN po.status = 'done' + THEN po.received::numeric / NULLIF(po.ordered::numeric, 0) * 100 + ELSE NULL + END)::numeric, 2), 0) as avg_fill_rate, + COUNT(CASE WHEN po.status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') THEN 1 END)::integer as active_orders, + COUNT(CASE WHEN po.status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') AND po.expected_date < CURRENT_DATE THEN 1 END)::integer as overdue_orders + FROM purchase_orders po + WHERE po.date >= CURRENT_DATE - INTERVAL '180 days' + GROUP BY po.vendor + ) + SELECT + vo.vendor, + vo.total_orders, + vo.avg_lead_time, + vo.on_time_delivery_rate, + vo.avg_fill_rate, + vo.active_orders, + vo.overdue_orders + FROM vendor_orders vo + ORDER BY vo.on_time_delivery_rate DESC + LIMIT 10 + `; + + console.log('Executing vendor performance query'); + const { rows } = await executeQuery(query); + + console.log(`Query returned ${rows.length} vendors`); + + // If no vendor data found, return dummy data - never return empty array + if (rows.length === 0) { + console.log('No vendor data found, returning dummy data'); + return res.json([ + { + vendor: "Example Vendor 1", + total_orders: 12, + avg_lead_time: 7.5, + on_time_delivery_rate: 92.5, + avg_fill_rate: 97.0, + active_orders: 3, + overdue_orders: 0 + }, + { + vendor: "Example Vendor 2", + total_orders: 8, + avg_lead_time: 10.2, + on_time_delivery_rate: 87.5, + avg_fill_rate: 95.5, + active_orders: 2, + overdue_orders: 1 + }, + { + vendor: "Example Vendor 3", + total_orders: 5, + avg_lead_time: 15.0, + on_time_delivery_rate: 80.0, + avg_fill_rate: 92.0, + active_orders: 1, + overdue_orders: 0 + } + ]); + } + + // Transform data to ensure numeric values are properly formatted + const formattedData = rows.map(row => ({ + vendor: row.vendor, + total_orders: Number(row.total_orders) || 0, + avg_lead_time: Number(row.avg_lead_time) || 0, + on_time_delivery_rate: Number(row.on_time_delivery_rate) || 0, + avg_fill_rate: Number(row.avg_fill_rate) || 0, + active_orders: Number(row.active_orders) || 0, + overdue_orders: Number(row.overdue_orders) || 0 + })); + + console.log('Returning vendor data:', formattedData); + res.json(formattedData); + } catch (err) { + console.error('Error fetching vendor performance:', err); + console.error('Error details:', err.message); + + // Return dummy data on error + res.json([ + { + vendor: "Example Vendor 1", + total_orders: 12, + avg_lead_time: 7.5, + on_time_delivery_rate: 92.5, + avg_fill_rate: 97.0, + active_orders: 3, + overdue_orders: 0 + }, + { + vendor: "Example Vendor 2", + total_orders: 8, + avg_lead_time: 10.2, + on_time_delivery_rate: 87.5, + avg_fill_rate: 95.5, + active_orders: 2, + overdue_orders: 1 + }, + { + vendor: "Example Vendor 3", + total_orders: 5, + avg_lead_time: 15.0, + on_time_delivery_rate: 80.0, + avg_fill_rate: 92.0, + active_orders: 1, + overdue_orders: 0 + } + ]); + } +}); + +// GET /dashboard/key-metrics +// Returns key business metrics and KPIs +router.get('/key-metrics', async (req, res) => { + const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); + try { + const { rows } = await executeQuery(` + WITH inventory_summary AS ( + SELECT + COUNT(*) as total_products, + SUM(p.stock_quantity * p.cost_price) as total_inventory_value, + AVG(pm.turnover_rate) as avg_turnover_rate, + COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count, + COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count + FROM products p + JOIN product_metrics pm ON p.pid = pm.pid + ), + sales_summary AS ( + SELECT + COUNT(DISTINCT order_number) as total_orders, + SUM(quantity) as total_units_sold, + SUM(price * quantity) as total_revenue, + AVG(price * quantity) as avg_order_value + FROM orders + WHERE canceled = false + AND date >= CURRENT_DATE - INTERVAL '${days} days' + ), + purchase_summary AS ( + SELECT + COUNT(DISTINCT po_id) as total_pos, + SUM(ordered * cost_price) as total_po_value, + COUNT(CASE WHEN status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') THEN 1 END) as open_pos + FROM purchase_orders + WHERE order_date >= CURRENT_DATE - INTERVAL '${days} days' + ) + SELECT + i.*, + s.*, + p.* + FROM inventory_summary i + CROSS JOIN sales_summary s + CROSS JOIN purchase_summary p + `); + res.json(rows[0] || {}); + } catch (err) { + console.error('Error fetching key metrics:', err); + res.status(500).json({ error: 'Failed to fetch key metrics' }); + } +}); + +// GET /dashboard/inventory-health +// Returns overall inventory health metrics +router.get('/inventory-health', async (req, res) => { + try { + const { rows } = await executeQuery(` + WITH stock_distribution AS ( + SELECT + COUNT(*) as total_products, + SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as healthy_stock_percent, + SUM(CASE WHEN pm.stock_status = 'Critical' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as critical_stock_percent, + SUM(CASE WHEN pm.stock_status = 'Reorder' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as reorder_stock_percent, + SUM(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as overstock_percent, + AVG(pm.turnover_rate) as avg_turnover_rate, + AVG(pm.days_of_inventory) as avg_days_inventory + FROM products p + JOIN product_metrics pm ON p.pid = pm.pid + WHERE p.replenishable = true + ), + value_distribution AS ( + SELECT + SUM(p.stock_quantity * p.cost_price) as total_inventory_value, + SUM(CASE + WHEN pm.stock_status = 'Healthy' + THEN p.stock_quantity * p.cost_price + ELSE 0 + END) * 100.0 / NULLIF(SUM(p.stock_quantity * p.cost_price), 0) as healthy_value_percent, + SUM(CASE + WHEN pm.stock_status = 'Critical' + THEN p.stock_quantity * p.cost_price + ELSE 0 + END) * 100.0 / NULLIF(SUM(p.stock_quantity * p.cost_price), 0) as critical_value_percent, + SUM(CASE + WHEN pm.stock_status = 'Overstocked' + THEN p.stock_quantity * p.cost_price + ELSE 0 + END) * 100.0 / NULLIF(SUM(p.stock_quantity * p.cost_price), 0) as overstock_value_percent + FROM products p + JOIN product_metrics pm ON p.pid = pm.pid + ), + category_health AS ( + SELECT + c.name as category_name, + COUNT(*) as category_products, + SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent, + AVG(pm.turnover_rate) as category_turnover_rate + FROM categories c + JOIN product_categories pc ON c.cat_id = pc.cat_id + JOIN products p ON pc.pid = p.pid + JOIN product_metrics pm ON p.pid = pm.pid + WHERE p.replenishable = true + GROUP BY c.cat_id, c.name + ) + SELECT + sd.*, + vd.*, + json_agg( + json_build_object( + 'category', ch.category_name, + 'products', ch.category_products, + 'healthy_percent', ch.category_healthy_percent, + 'turnover_rate', ch.category_turnover_rate + ) + ) as category_health + FROM stock_distribution sd + CROSS JOIN value_distribution vd + CROSS JOIN category_health ch + GROUP BY + sd.total_products, + sd.healthy_stock_percent, + sd.critical_stock_percent, + sd.reorder_stock_percent, + sd.overstock_percent, + sd.avg_turnover_rate, + sd.avg_days_inventory, + vd.total_inventory_value, + vd.healthy_value_percent, + vd.critical_value_percent, + vd.overstock_value_percent + `); + + if (rows.length === 0) { + return res.json({ + total_products: 0, + healthy_stock_percent: 0, + critical_stock_percent: 0, + reorder_stock_percent: 0, + overstock_percent: 0, + avg_turnover_rate: 0, + avg_days_inventory: 0, + total_inventory_value: 0, + healthy_value_percent: 0, + critical_value_percent: 0, + overstock_value_percent: 0, + category_health: [] + }); + } + + res.json(rows[0]); + } catch (err) { + console.error('Error fetching inventory health:', err); + res.status(500).json({ error: 'Failed to fetch inventory health' }); + } +}); + +// GET /dashboard/replenish/products +// Returns list of products to replenish +router.get('/replenish/products', async (req, res) => { + const limit = parseInt(req.query.limit) || 50; + try { + const { rows } = await executeQuery(` + SELECT + pm.pid, + pm.sku, + pm.title, + pm.current_stock AS stock_quantity, + pm.sales_velocity_daily AS daily_sales_avg, + pm.replenishment_units AS reorder_qty, + pm.date_last_received AS last_purchase_date + FROM product_metrics pm + WHERE pm.is_replenishable = true + AND (pm.status IN ('Critical', 'Reorder') + OR pm.current_stock < 0) + AND pm.replenishment_units > 0 + ORDER BY + CASE pm.status + WHEN 'Critical' THEN 1 + WHEN 'Reorder' THEN 2 + END, + pm.replenishment_cost DESC + LIMIT $1 + `, [limit]); + res.json(rows); + } catch (err) { + console.error('Error fetching products to replenish:', err); + res.status(500).json({ error: 'Failed to fetch products to replenish' }); + } +}); + +// GET /dashboard/sales-overview +// Returns sales overview data for the chart in Overview.tsx +router.get('/sales-overview', async (req, res) => { + try { + const { rows } = await executeQuery(` + SELECT + DATE(date) as date, + ROUND(SUM(price * quantity)::numeric, 3) as total + FROM orders + WHERE canceled = false + AND date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY DATE(date) + ORDER BY date ASC + `); + + // If no data, generate dummy data + if (rows.length === 0) { + console.log('No sales overview data found, returning dummy data'); + const dummyData = []; + const today = new Date(); + + // Generate 30 days of dummy data + for (let i = 0; i < 30; i++) { + const date = new Date(today); + date.setDate(today.getDate() - (29 - i)); + dummyData.push({ + date: date.toISOString().split('T')[0], + total: Math.floor(1000 + Math.random() * 2000) + }); + } + + return res.json(dummyData); + } + + res.json(rows); + } catch (err) { + console.error('Error fetching sales overview:', err); + + // Generate dummy data on error + const dummyData = []; + const today = new Date(); + + // Generate 30 days of dummy data + for (let i = 0; i < 30; i++) { + const date = new Date(today); + date.setDate(today.getDate() - (29 - i)); + dummyData.push({ + date: date.toISOString().split('T')[0], + total: Math.floor(1000 + Math.random() * 2000) + }); + } + + res.json(dummyData); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/data-management.js b/inventory-server/src/routes/data-management.js new file mode 100644 index 0000000..984710d --- /dev/null +++ b/inventory-server/src/routes/data-management.js @@ -0,0 +1,440 @@ +const express = require('express'); +const router = express.Router(); +const { spawn } = require('child_process'); +const path = require('path'); +const db = require('../utils/db'); + +// Debug middleware MUST be first +router.use((req, res, next) => { + console.log(`[CSV Route Debug] ${req.method} ${req.path}`); + next(); +}); + +// Store active processes and their progress +let activeImport = null; +let importProgress = null; +let activeFullUpdate = null; +let activeFullReset = null; + +// SSE clients for progress updates +const updateClients = new Set(); +const importClients = new Set(); +const resetClients = new Set(); +const resetMetricsClients = new Set(); +const calculateMetricsClients = new Set(); +const fullUpdateClients = new Set(); +const fullResetClients = new Set(); + +// Helper to send progress to specific clients +function sendProgressToClients(clients, data) { + // If data is a string, send it directly + // If it's an object, convert it to JSON + const message = typeof data === 'string' + ? `data: ${data}\n\n` + : `data: ${JSON.stringify(data)}\n\n`; + + clients.forEach(client => { + try { + client.write(message); + // Immediately flush the response + if (typeof client.flush === 'function') { + client.flush(); + } + } catch (error) { + // Silently remove failed client + clients.delete(client); + } + }); +} + +// Helper to run a script and stream progress +function runScript(scriptPath, type, clients) { + return new Promise((resolve, reject) => { + // Kill any existing process of this type + let activeProcess; + switch (type) { + case 'update': + if (activeFullUpdate) { + try { activeFullUpdate.kill(); } catch (e) { } + } + activeProcess = activeFullUpdate; + break; + case 'reset': + if (activeFullReset) { + try { activeFullReset.kill(); } catch (e) { } + } + activeProcess = activeFullReset; + break; + } + + const child = spawn('node', [scriptPath], { + stdio: ['inherit', 'pipe', 'pipe'] + }); + + switch (type) { + case 'update': + activeFullUpdate = child; + break; + case 'reset': + activeFullReset = child; + break; + } + + let output = ''; + + child.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + + // Split by lines to handle multiple JSON outputs + const lines = text.split('\n'); + lines.filter(line => line.trim()).forEach(line => { + try { + // Try to parse as JSON but don't let it affect the display + const jsonData = JSON.parse(line); + // Only end the process if we get a final status + if (jsonData.status === 'complete' || jsonData.status === 'error' || jsonData.status === 'cancelled') { + if (jsonData.status === 'complete' && !jsonData.operation?.includes('complete')) { + // Don't close for intermediate completion messages + sendProgressToClients(clients, line); + return; + } + // Close only on final completion/error/cancellation + switch (type) { + case 'update': + activeFullUpdate = null; + break; + case 'reset': + activeFullReset = null; + break; + } + if (jsonData.status === 'error') { + reject(new Error(jsonData.error || 'Unknown error')); + } else { + resolve({ output }); + } + } + } catch (e) { + // Not JSON, just display as is + } + // Always send the raw line + sendProgressToClients(clients, line); + }); + }); + + child.stderr.on('data', (data) => { + const text = data.toString(); + console.error(text); + // Send stderr output directly too + sendProgressToClients(clients, text); + }); + + child.on('close', (code) => { + switch (type) { + case 'update': + activeFullUpdate = null; + break; + case 'reset': + activeFullReset = null; + break; + } + + if (code !== 0) { + const error = `Script ${scriptPath} exited with code ${code}`; + sendProgressToClients(clients, error); + reject(new Error(error)); + } + // Don't resolve here - let the completion message from the script trigger the resolve + }); + + child.on('error', (err) => { + switch (type) { + case 'update': + activeFullUpdate = null; + break; + case 'reset': + activeFullReset = null; + break; + } + sendProgressToClients(clients, err.message); + reject(err); + }); + }); +} + +// Progress endpoints +router.get('/:type/progress', (req, res) => { + const { type } = req.params; + if (!['update', 'reset'].includes(type)) { + return res.status(400).json({ error: 'Invalid operation type' }); + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': req.headers.origin || '*', + 'Access-Control-Allow-Credentials': 'true' + }); + + // Add this client to the correct set + const clients = type === 'update' ? fullUpdateClients : fullResetClients; + clients.add(res); + + // Send initial connection message + sendProgressToClients(new Set([res]), JSON.stringify({ + status: 'running', + operation: 'Initializing connection...' + })); + + // Handle client disconnect + req.on('close', () => { + clients.delete(res); + }); +}); + +// GET /status - Check for active processes +router.get('/status', (req, res) => { + try { + const hasActiveUpdate = activeFullUpdate !== null; + const hasActiveReset = activeFullReset !== null; + + if (hasActiveUpdate || hasActiveReset) { + res.json({ + active: true, + progress: { + status: 'running', + operation: hasActiveUpdate ? 'Full update in progress' : 'Full reset in progress', + type: hasActiveUpdate ? 'update' : 'reset' + } + }); + } else { + res.json({ + active: false, + progress: null + }); + } + } catch (error) { + console.error('Error checking status:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Route to cancel active process +router.post('/cancel', (req, res) => { + let killed = false; + + // Get the operation type from the request + const { type } = req.query; + const clients = type === 'update' ? fullUpdateClients : fullResetClients; + const activeProcess = type === 'update' ? activeFullUpdate : activeFullReset; + + if (activeProcess) { + try { + activeProcess.kill('SIGTERM'); + if (type === 'update') { + activeFullUpdate = null; + } else { + activeFullReset = null; + } + killed = true; + sendProgressToClients(clients, JSON.stringify({ + status: 'cancelled', + operation: 'Operation cancelled' + })); + } catch (err) { + console.error(`Error killing ${type} process:`, err); + } + } + + if (killed) { + res.json({ success: true }); + } else { + res.status(404).json({ error: 'No active process to cancel' }); + } +}); + +// POST /csv/full-update - Run full update script +router.post('/full-update', async (req, res) => { + try { + const scriptPath = path.join(__dirname, '../../scripts/full-update.js'); + runScript(scriptPath, 'update', fullUpdateClients) + .catch(error => { + console.error('Update failed:', error); + }); + res.status(202).json({ message: 'Update started' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// POST /csv/full-reset - Run full reset script +router.post('/full-reset', async (req, res) => { + try { + const scriptPath = path.join(__dirname, '../../scripts/full-reset.js'); + runScript(scriptPath, 'reset', fullResetClients) + .catch(error => { + console.error('Reset failed:', error); + }); + res.status(202).json({ message: 'Reset started' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// GET /history/import - Get recent import history +router.get('/history/import', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // First check which columns exist + const { rows: columns } = await pool.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'import_history' + AND column_name IN ('records_deleted', 'records_skipped', 'total_processed') + `); + + const hasDeletedColumn = columns.some(col => col.column_name === 'records_deleted'); + const hasSkippedColumn = columns.some(col => col.column_name === 'records_skipped'); + const hasTotalProcessedColumn = columns.some(col => col.column_name === 'total_processed'); + + // Build query dynamically based on available columns + const query = ` + SELECT + id, + start_time, + end_time, + status, + error_message, + records_added::integer, + records_updated::integer, + ${hasDeletedColumn ? 'records_deleted::integer,' : '0 as records_deleted,'} + ${hasSkippedColumn ? 'records_skipped::integer,' : '0 as records_skipped,'} + ${hasTotalProcessedColumn ? 'total_processed::integer,' : '0 as total_processed,'} + is_incremental, + additional_info, + EXTRACT(EPOCH FROM (COALESCE(end_time, NOW()) - start_time)) / 60 as duration_minutes + FROM import_history + ORDER BY start_time DESC + LIMIT 20 + `; + + const { rows } = await pool.query(query); + res.json(rows || []); + } catch (error) { + console.error('Error fetching import history:', error); + res.status(500).json({ error: error.message }); + } +}); + +// GET /history/calculate - Get recent calculation history +router.get('/history/calculate', async (req, res) => { + try { + const pool = req.app.locals.pool; + const { rows } = await pool.query(` + SELECT + id, + start_time, + end_time, + EXTRACT(EPOCH FROM (COALESCE(end_time, NOW()) - start_time)) / 60 as duration_minutes, + duration_seconds, + status, + error_message, + total_products, + total_orders, + total_purchase_orders, + processed_products, + processed_orders, + processed_purchase_orders, + additional_info + FROM calculate_history + ORDER BY start_time DESC + LIMIT 20 + `); + res.json(rows || []); + } catch (error) { + console.error('Error fetching calculate history:', error); + res.status(500).json({ error: error.message }); + } +}); + +// GET /status/modules - Get module calculation status +router.get('/status/modules', async (req, res) => { + try { + const pool = req.app.locals.pool; + const { rows } = await pool.query(` + SELECT + module_name, + last_calculation_timestamp::timestamp + FROM calculate_status + ORDER BY module_name + `); + res.json(rows || []); + } catch (error) { + console.error('Error fetching module status:', error); + res.status(500).json({ error: error.message }); + } +}); + +// GET /status/tables - Get table sync status +router.get('/status/tables', async (req, res) => { + try { + const pool = req.app.locals.pool; + const { rows } = await pool.query(` + SELECT + table_name, + last_sync_timestamp::timestamp + FROM sync_status + ORDER BY table_name + `); + res.json(rows || []); + } catch (error) { + console.error('Error fetching table status:', error); + res.status(500).json({ error: error.message }); + } +}); + +// GET /status/table-counts - Get record counts for all tables +router.get('/status/table-counts', async (req, res) => { + try { + const pool = req.app.locals.pool; + const tables = [ + // Core tables + 'products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'receivings', + // New metrics tables + 'product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics', + // Config tables + 'settings_global', 'settings_vendor', 'settings_product' + ]; + + const counts = await Promise.all( + tables.map(table => + pool.query(`SELECT COUNT(*) as count FROM ${table}`) + .then(result => ({ + table_name: table, + count: parseInt(result.rows[0].count) + })) + .catch(err => ({ + table_name: table, + count: null, + error: err.message + })) + ) + ); + + // Group tables by type + const groupedCounts = { + core: counts.filter(c => ['products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'receivings'].includes(c.table_name)), + metrics: counts.filter(c => ['product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics'].includes(c.table_name)), + config: counts.filter(c => ['settings_global', 'settings_vendor', 'settings_product'].includes(c.table_name)) + }; + + res.json(groupedCounts); + } catch (error) { + console.error('Error fetching table counts:', error); + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js new file mode 100644 index 0000000..34b732c --- /dev/null +++ b/inventory-server/src/routes/import.js @@ -0,0 +1,1152 @@ +const express = require('express'); +const router = express.Router(); +const { Client } = require('ssh2'); +const mysql = require('mysql2/promise'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); + +// Create uploads directory if it doesn't exist +const uploadsDir = path.join('/var/www/html/inventory/uploads/products'); +const reusableUploadsDir = path.join('/var/www/html/inventory/uploads/reusable'); +fs.mkdirSync(uploadsDir, { recursive: true }); +fs.mkdirSync(reusableUploadsDir, { recursive: true }); + +// Create a Map to track image upload times and their scheduled deletion +const imageUploadMap = new Map(); + +// Connection pooling and cache configuration +const connectionCache = { + ssh: null, + dbConnection: null, + lastUsed: 0, + isConnecting: false, + connectionPromise: null, + // Cache expiration time in milliseconds (5 minutes) + expirationTime: 5 * 60 * 1000, + // Cache for query results (key: query string, value: {data, timestamp}) + queryCache: new Map(), + // Cache duration for different query types in milliseconds + cacheDuration: { + 'field-options': 30 * 60 * 1000, // 30 minutes for field options + 'product-lines': 10 * 60 * 1000, // 10 minutes for product lines + 'sublines': 10 * 60 * 1000, // 10 minutes for sublines + 'default': 60 * 1000 // 1 minute default + } +}; + +// Function to schedule image deletion after 24 hours +const scheduleImageDeletion = (filename, filePath) => { + // Only schedule deletion for images in the products folder + if (!filePath.includes('/uploads/products/')) { + console.log(`Skipping deletion for non-product image: ${filename}`); + return; + } + + // Delete any existing timeout for this file + if (imageUploadMap.has(filename)) { + clearTimeout(imageUploadMap.get(filename).timeoutId); + } + + // Schedule deletion after 24 hours (24 * 60 * 60 * 1000 ms) + const timeoutId = setTimeout(() => { + console.log(`Auto-deleting image after 24 hours: ${filename}`); + + // Check if file exists before trying to delete + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + console.log(`Successfully auto-deleted image: ${filename}`); + } catch (error) { + console.error(`Error auto-deleting image ${filename}:`, error); + } + } else { + console.log(`File already deleted: ${filename}`); + } + + // Remove from tracking map + imageUploadMap.delete(filename); + }, 24 * 60 * 60 * 1000); // 24 hours + + // Store upload time and timeout ID + imageUploadMap.set(filename, { + uploadTime: new Date(), + timeoutId: timeoutId, + filePath: filePath + }); +}; + +// Function to clean up scheduled deletions on server restart +const cleanupImagesOnStartup = () => { + console.log('Checking for images to clean up...'); + + // Check if uploads directory exists + if (!fs.existsSync(uploadsDir)) { + console.log('Uploads directory does not exist'); + return; + } + + // Read all files in the directory + fs.readdir(uploadsDir, (err, files) => { + if (err) { + console.error('Error reading uploads directory:', err); + return; + } + + const now = new Date(); + let countDeleted = 0; + + files.forEach(filename => { + const filePath = path.join(uploadsDir, filename); + + // Get file stats + try { + const stats = fs.statSync(filePath); + const fileCreationTime = stats.birthtime || stats.ctime; // birthtime might not be available on all systems + const ageMs = now.getTime() - fileCreationTime.getTime(); + + // If file is older than 24 hours, delete it + if (ageMs > 24 * 60 * 60 * 1000) { + fs.unlinkSync(filePath); + countDeleted++; + console.log(`Deleted old image on startup: ${filename} (age: ${Math.round(ageMs / (60 * 60 * 1000))} hours)`); + } else { + // Schedule deletion for remaining time + const remainingMs = (24 * 60 * 60 * 1000) - ageMs; + console.log(`Scheduling deletion for ${filename} in ${Math.round(remainingMs / (60 * 60 * 1000))} hours`); + + const timeoutId = setTimeout(() => { + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + console.log(`Successfully auto-deleted scheduled image: ${filename}`); + } catch (error) { + console.error(`Error auto-deleting scheduled image ${filename}:`, error); + } + } + imageUploadMap.delete(filename); + }, remainingMs); + + imageUploadMap.set(filename, { + uploadTime: fileCreationTime, + timeoutId: timeoutId, + filePath: filePath + }); + } + } catch (error) { + console.error(`Error processing file ${filename}:`, error); + } + }); + + console.log(`Cleanup completed: ${countDeleted} old images deleted, ${imageUploadMap.size} images scheduled for deletion`); + }); +}; + +// Run cleanup on server start +cleanupImagesOnStartup(); + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + console.log(`Saving to: ${uploadsDir}`); + cb(null, uploadsDir); + }, + filename: function (req, file, cb) { + // Create unique filename with original extension + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + + // Make sure we preserve the original file extension + let fileExt = path.extname(file.originalname).toLowerCase(); + + // Ensure there is a proper extension based on mimetype if none exists + if (!fileExt) { + switch (file.mimetype) { + case 'image/jpeg': fileExt = '.jpg'; break; + case 'image/png': fileExt = '.png'; break; + case 'image/gif': fileExt = '.gif'; break; + case 'image/webp': fileExt = '.webp'; break; + default: fileExt = '.jpg'; // Default to jpg + } + } + + const fileName = `${req.body.upc || 'product'}-${uniqueSuffix}${fileExt}`; + console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`); + cb(null, fileName); + } +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB max file size + }, + fileFilter: function (req, file, cb) { + // Accept only image files + const filetypes = /jpeg|jpg|png|gif|webp/; + const mimetype = filetypes.test(file.mimetype); + const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); + + if (mimetype && extname) { + return cb(null, true); + } + cb(new Error('Only image files are allowed')); + } +}); + +// Modified function to get a database connection with connection pooling +async function getDbConnection() { + const now = Date.now(); + + // Check if we need to refresh the connection due to inactivity + const needsRefresh = !connectionCache.ssh || + !connectionCache.dbConnection || + (now - connectionCache.lastUsed > connectionCache.expirationTime); + + // If connection is still valid, update last used time and return existing connection + if (!needsRefresh) { + connectionCache.lastUsed = now; + return { + ssh: connectionCache.ssh, + connection: connectionCache.dbConnection + }; + } + + // If another request is already establishing a connection, wait for that promise + if (connectionCache.isConnecting && connectionCache.connectionPromise) { + try { + await connectionCache.connectionPromise; + return { + ssh: connectionCache.ssh, + connection: connectionCache.dbConnection + }; + } catch (error) { + // If that connection attempt failed, we'll try again below + console.error('Error waiting for existing connection:', error); + } + } + + // Close existing connections if they exist + if (connectionCache.dbConnection) { + try { + await connectionCache.dbConnection.end(); + } catch (error) { + console.error('Error closing existing database connection:', error); + } + } + + if (connectionCache.ssh) { + try { + connectionCache.ssh.end(); + } catch (error) { + console.error('Error closing existing SSH connection:', error); + } + } + + // Mark that we're establishing a new connection + connectionCache.isConnecting = true; + + // Create a new promise for this connection attempt + connectionCache.connectionPromise = setupSshTunnel().then(tunnel => { + const { ssh, stream, dbConfig } = tunnel; + + return mysql.createConnection({ + ...dbConfig, + stream + }).then(connection => { + // Store the new connections + connectionCache.ssh = ssh; + connectionCache.dbConnection = connection; + connectionCache.lastUsed = Date.now(); + connectionCache.isConnecting = false; + + return { + ssh, + connection + }; + }); + }).catch(error => { + connectionCache.isConnecting = false; + throw error; + }); + + // Wait for the connection to be established + return connectionCache.connectionPromise; +} + +// Helper function to get cached query results or execute query if not cached +async function getCachedQuery(cacheKey, queryType, queryFn) { + // Get cache duration based on query type + const cacheDuration = connectionCache.cacheDuration[queryType] || connectionCache.cacheDuration.default; + + // Check if we have a valid cached result + const cachedResult = connectionCache.queryCache.get(cacheKey); + const now = Date.now(); + + if (cachedResult && (now - cachedResult.timestamp < cacheDuration)) { + console.log(`Cache hit for ${queryType} query: ${cacheKey}`); + return cachedResult.data; + } + + // No valid cache found, execute the query + console.log(`Cache miss for ${queryType} query: ${cacheKey}`); + const result = await queryFn(); + + // Cache the result + connectionCache.queryCache.set(cacheKey, { + data: result, + timestamp: now + }); + + return result; +} + +// Helper function to setup SSH tunnel - ONLY USED BY getDbConnection NOW +async function setupSshTunnel() { + const sshConfig = { + host: process.env.PROD_SSH_HOST, + port: process.env.PROD_SSH_PORT || 22, + username: process.env.PROD_SSH_USER, + privateKey: process.env.PROD_SSH_KEY_PATH + ? require('fs').readFileSync(process.env.PROD_SSH_KEY_PATH) + : undefined, + compress: true + }; + + const dbConfig = { + host: process.env.PROD_DB_HOST || 'localhost', + user: process.env.PROD_DB_USER, + password: process.env.PROD_DB_PASSWORD, + database: process.env.PROD_DB_NAME, + port: process.env.PROD_DB_PORT || 3306, + timezone: 'Z' + }; + + return new Promise((resolve, reject) => { + const ssh = new Client(); + + ssh.on('error', (err) => { + console.error('SSH connection error:', err); + reject(err); + }); + + ssh.on('ready', () => { + ssh.forwardOut( + '127.0.0.1', + 0, + dbConfig.host, + dbConfig.port, + (err, stream) => { + if (err) reject(err); + resolve({ ssh, stream, dbConfig }); + } + ); + }).connect(sshConfig); + }); +} + +// Image upload endpoint +router.post('/upload-image', upload.single('image'), (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No image file provided' }); + } + + // Log file information + console.log('File uploaded:', { + filename: req.file.filename, + originalname: req.file.originalname, + mimetype: req.file.mimetype, + size: req.file.size, + path: req.file.path + }); + + // Ensure the file exists + const filePath = path.join(uploadsDir, req.file.filename); + if (!fs.existsSync(filePath)) { + return res.status(500).json({ error: 'File was not saved correctly' }); + } + + // Log file access permissions + fs.access(filePath, fs.constants.R_OK, (err) => { + if (err) { + console.error('File permission issue:', err); + } else { + console.log('File is readable'); + } + }); + + // Create URL for the uploaded file - using an absolute URL with domain + // This will generate a URL like: https://acot.site/uploads/products/filename.jpg + const baseUrl = 'https://acot.site'; + const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`; + + // Schedule this image for deletion in 24 hours + scheduleImageDeletion(req.file.filename, filePath); + + // Return success response with image URL + res.status(200).json({ + success: true, + imageUrl, + fileName: req.file.filename, + mimetype: req.file.mimetype, + fullPath: filePath, + message: 'Image uploaded successfully (will auto-delete after 24 hours)' + }); + + } catch (error) { + console.error('Error uploading image:', error); + res.status(500).json({ error: error.message || 'Failed to upload image' }); + } +}); + +// Image deletion endpoint +router.delete('/delete-image', (req, res) => { + try { + const { filename } = req.body; + + if (!filename) { + return res.status(400).json({ error: 'Filename is required' }); + } + + const filePath = path.join(uploadsDir, filename); + + // Check if file exists + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'File not found' }); + } + + // Only allow deletion of images in the products folder + if (!filePath.includes('/uploads/products/')) { + return res.status(403).json({ + error: 'Cannot delete images outside the products folder', + message: 'This image is in a protected folder and cannot be deleted through this endpoint' + }); + } + + // Delete the file + fs.unlinkSync(filePath); + + // Clear any scheduled deletion for this file + if (imageUploadMap.has(filename)) { + clearTimeout(imageUploadMap.get(filename).timeoutId); + imageUploadMap.delete(filename); + } + + // Return success response + res.status(200).json({ + success: true, + message: 'Image deleted successfully' + }); + + } catch (error) { + console.error('Error deleting image:', error); + res.status(500).json({ error: error.message || 'Failed to delete image' }); + } +}); + +// Get all options for import fields +router.get('/field-options', async (req, res) => { + try { + // Use cached connection + const { connection } = await getDbConnection(); + + const cacheKey = 'field-options'; + const result = await getCachedQuery(cacheKey, 'field-options', async () => { + // Fetch companies (type 1) + const [companies] = await connection.query(` + SELECT cat_id, name + FROM product_categories + WHERE type = 1 + ORDER BY name + `); + + // Fetch artists (type 40) + const [artists] = await connection.query(` + SELECT cat_id, name + FROM product_categories + WHERE type = 40 + ORDER BY name + `); + + // Fetch sizes (type 50) + const [sizes] = await connection.query(` + SELECT cat_id, name + FROM product_categories + WHERE type = 50 + ORDER BY name + `); + + // Fetch themes with subthemes + const [themes] = await connection.query(` + SELECT t.cat_id, t.name AS display_name, t.type, t.name AS sort_theme, + '' AS sort_subtheme, 1 AS level_order + FROM product_categories t + WHERE t.type = 20 + UNION ALL + SELECT ts.cat_id, CONCAT(t.name,' - ',ts.name) AS display_name, ts.type, + t.name AS sort_theme, ts.name AS sort_subtheme, 2 AS level_order + FROM product_categories ts + JOIN product_categories t ON ts.master_cat_id = t.cat_id + WHERE ts.type = 21 AND t.type = 20 + ORDER BY sort_theme, sort_subtheme + `); + + // Fetch categories with all levels + const [categories] = await connection.query(` + SELECT s.cat_id, s.name AS display_name, s.type, s.name AS sort_section, + '' AS sort_category, '' AS sort_subcategory, '' AS sort_subsubcategory, + 1 AS level_order + FROM product_categories s + WHERE s.type = 10 + UNION ALL + SELECT c.cat_id, CONCAT(s.name,' - ',c.name) AS display_name, c.type, + s.name AS sort_section, c.name AS sort_category, '' AS sort_subcategory, + '' AS sort_subsubcategory, 2 AS level_order + FROM product_categories c + JOIN product_categories s ON c.master_cat_id = s.cat_id + WHERE c.type = 11 AND s.type = 10 + UNION ALL + SELECT sc.cat_id, CONCAT(s.name,' - ',c.name,' - ',sc.name) AS display_name, + sc.type, s.name AS sort_section, c.name AS sort_category, + sc.name AS sort_subcategory, '' AS sort_subsubcategory, 3 AS level_order + FROM product_categories sc + JOIN product_categories c ON sc.master_cat_id = c.cat_id + JOIN product_categories s ON c.master_cat_id = s.cat_id + WHERE sc.type = 12 AND c.type = 11 AND s.type = 10 + UNION ALL + SELECT ssc.cat_id, CONCAT(s.name,' - ',c.name,' - ',sc.name,' - ',ssc.name) AS display_name, + ssc.type, s.name AS sort_section, c.name AS sort_category, + sc.name AS sort_subcategory, ssc.name AS sort_subsubcategory, 4 AS level_order + FROM product_categories ssc + JOIN product_categories sc ON ssc.master_cat_id = sc.cat_id + JOIN product_categories c ON sc.master_cat_id = c.cat_id + JOIN product_categories s ON c.master_cat_id = s.cat_id + WHERE ssc.type = 13 AND sc.type = 12 AND c.type = 11 AND s.type = 10 + ORDER BY sort_section, sort_category, sort_subcategory, sort_subsubcategory + `); + + // Fetch colors + const [colors] = await connection.query(` + SELECT color, name, hex_color + FROM product_color_list + ORDER BY \`order\` + `); + + // Fetch suppliers + const [suppliers] = await connection.query(` + SELECT supplierid as value, companyname as label + FROM suppliers + WHERE companyname <> '' + ORDER BY companyname + `); + + // Fetch tax categories + const [taxCategories] = await connection.query(` + SELECT CAST(tax_code_id AS CHAR) as value, name as label + FROM product_tax_codes + ORDER BY tax_code_id = 0 DESC, name + `); + + // Format and return all options + return { + companies: companies.map(c => ({ label: c.name, value: c.cat_id.toString() })), + artists: artists.map(a => ({ label: a.name, value: a.cat_id.toString() })), + sizes: sizes.map(s => ({ label: s.name, value: s.cat_id.toString() })), + themes: themes.map(t => ({ + label: t.display_name, + value: t.cat_id.toString(), + type: t.type, + level: t.level_order + })), + categories: categories.map(c => ({ + label: c.display_name, + value: c.cat_id.toString(), + type: c.type, + level: c.level_order + })), + colors: colors.map(c => ({ + label: c.name, + value: c.color, + hexColor: c.hex_color + })), + suppliers: suppliers, + taxCategories: taxCategories, + shippingRestrictions: [ + { label: "None", value: "0" }, + { label: "US Only", value: "1" }, + { label: "Limited Quantity", value: "2" }, + { label: "US/CA Only", value: "3" }, + { label: "No FedEx 2 Day", value: "4" }, + { label: "North America Only", value: "5" } + ] + }; + }); + + // Add debugging to verify category types + console.log(`Returning ${result.categories.length} categories with types: ${Array.from(new Set(result.categories.map(c => c.type))).join(', ')}`); + + res.json(result); + } catch (error) { + console.error('Error fetching import field options:', error); + res.status(500).json({ error: 'Failed to fetch import field options' }); + } +}); + +// Get product lines for a specific company +router.get('/product-lines/:companyId', async (req, res) => { + try { + // Use cached connection + const { connection } = await getDbConnection(); + + const companyId = req.params.companyId; + const cacheKey = `product-lines-${companyId}`; + + const lines = await getCachedQuery(cacheKey, 'product-lines', async () => { + const [queryResult] = await connection.query(` + SELECT cat_id as value, name as label + FROM product_categories + WHERE type = 2 + AND master_cat_id = ? + ORDER BY name + `, [companyId]); + + return queryResult.map(l => ({ label: l.label, value: l.value.toString() })); + }); + + res.json(lines); + } catch (error) { + console.error('Error fetching product lines:', error); + res.status(500).json({ error: 'Failed to fetch product lines' }); + } +}); + +// Get sublines for a specific product line +router.get('/sublines/:lineId', async (req, res) => { + try { + // Use cached connection + const { connection } = await getDbConnection(); + + const lineId = req.params.lineId; + const cacheKey = `sublines-${lineId}`; + + const sublines = await getCachedQuery(cacheKey, 'sublines', async () => { + const [queryResult] = await connection.query(` + SELECT cat_id as value, name as label + FROM product_categories + WHERE type = 3 + AND master_cat_id = ? + ORDER BY name + `, [lineId]); + + return queryResult.map(s => ({ label: s.label, value: s.value.toString() })); + }); + + res.json(sublines); + } catch (error) { + console.error('Error fetching sublines:', error); + res.status(500).json({ error: 'Failed to fetch sublines' }); + } +}); + +// Add a simple endpoint to check file existence and permissions +router.get('/check-file/:filename', (req, res) => { + const { filename } = req.params; + + // Prevent directory traversal + if (filename.includes('..') || filename.includes('/')) { + return res.status(400).json({ error: 'Invalid filename' }); + } + + // First check in products directory + let filePath = path.join(uploadsDir, filename); + let exists = fs.existsSync(filePath); + + // If not found in products, check in reusable directory + if (!exists) { + filePath = path.join(reusableUploadsDir, filename); + exists = fs.existsSync(filePath); + } + + try { + // Check if file exists + if (!exists) { + return res.status(404).json({ + error: 'File not found', + path: filePath, + exists: false, + readable: false + }); + } + + // Check if file is readable + fs.accessSync(filePath, fs.constants.R_OK); + + // Get file stats + const stats = fs.statSync(filePath); + + return res.json({ + filename, + path: filePath, + exists: true, + readable: true, + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + size: stats.size, + created: stats.birthtime, + modified: stats.mtime, + permissions: stats.mode.toString(8) + }); + } catch (error) { + return res.status(500).json({ + error: error.message, + path: filePath, + exists: fs.existsSync(filePath), + readable: false + }); + } +}); + +// List all files in uploads directory +router.get('/list-uploads', (req, res) => { + try { + const { directory = 'products' } = req.query; + + // Determine which directory to list + let targetDir; + if (directory === 'reusable') { + targetDir = reusableUploadsDir; + } else { + targetDir = uploadsDir; // default to products + } + + if (!fs.existsSync(targetDir)) { + return res.status(404).json({ error: 'Uploads directory not found', path: targetDir }); + } + + const files = fs.readdirSync(targetDir); + const fileDetails = files.map(file => { + const filePath = path.join(targetDir, file); + try { + const stats = fs.statSync(filePath); + return { + filename: file, + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + size: stats.size, + created: stats.birthtime, + modified: stats.mtime, + permissions: stats.mode.toString(8) + }; + } catch (error) { + return { filename: file, error: error.message }; + } + }); + + return res.json({ + directory: targetDir, + type: directory, + count: files.length, + files: fileDetails + }); + } catch (error) { + return res.status(500).json({ error: error.message }); + } +}); + +// Search products from production database +router.get('/search-products', async (req, res) => { + const { q, company, dateRange } = req.query; + + if (!q) { + return res.status(400).json({ error: 'Search term is required' }); + } + + try { + const { connection } = await getDbConnection(); + + // Build WHERE clause with additional filters + let whereClause = ` + WHERE ( + p.description LIKE ? OR + p.itemnumber LIKE ? OR + p.upc LIKE ? OR + pc1.name LIKE ? OR + s.companyname LIKE ? + )`; + + // Add company filter if provided + if (company) { + whereClause += ` AND p.company = ${connection.escape(company)}`; + } + + // Add date range filter if provided + if (dateRange) { + let dateCondition; + const now = new Date(); + + switch(dateRange) { + case '1week': + // Last week: date is after (current date - 7 days) + const weekAgo = new Date(now); + weekAgo.setDate(now.getDate() - 7); + dateCondition = `p.datein >= ${connection.escape(weekAgo.toISOString().slice(0, 10))}`; + break; + case '1month': + // Last month: date is after (current date - 30 days) + const monthAgo = new Date(now); + monthAgo.setDate(now.getDate() - 30); + dateCondition = `p.datein >= ${connection.escape(monthAgo.toISOString().slice(0, 10))}`; + break; + case '2months': + // Last 2 months: date is after (current date - 60 days) + const twoMonthsAgo = new Date(now); + twoMonthsAgo.setDate(now.getDate() - 60); + dateCondition = `p.datein >= ${connection.escape(twoMonthsAgo.toISOString().slice(0, 10))}`; + break; + case '3months': + // Last 3 months: date is after (current date - 90 days) + const threeMonthsAgo = new Date(now); + threeMonthsAgo.setDate(now.getDate() - 90); + dateCondition = `p.datein >= ${connection.escape(threeMonthsAgo.toISOString().slice(0, 10))}`; + break; + case '6months': + // Last 6 months: date is after (current date - 180 days) + const sixMonthsAgo = new Date(now); + sixMonthsAgo.setDate(now.getDate() - 180); + dateCondition = `p.datein >= ${connection.escape(sixMonthsAgo.toISOString().slice(0, 10))}`; + break; + case '1year': + // Last year: date is after (current date - 365 days) + const yearAgo = new Date(now); + yearAgo.setDate(now.getDate() - 365); + dateCondition = `p.datein >= ${connection.escape(yearAgo.toISOString().slice(0, 10))}`; + break; + default: + // If an unrecognized value is provided, don't add a date condition + dateCondition = null; + } + + if (dateCondition) { + whereClause += ` AND ${dateCondition}`; + } + } + + // Special case for wildcard search + const isWildcardSearch = q === '*'; + const searchPattern = isWildcardSearch ? '%' : `%${q}%`; + const exactPattern = isWildcardSearch ? '%' : q; + + // Search for products based on various fields + const query = ` + SELECT + p.pid, + p.description AS title, + p.notes AS description, + p.itemnumber AS sku, + p.upc AS barcode, + p.harmonized_tariff_code, + pcp.price_each AS price, + p.sellingprice AS regular_price, + CASE + WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) + THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0) + ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) + END AS cost_price, + s.companyname AS vendor, + sid.supplier_itemnumber AS vendor_reference, + sid.notions_itemnumber AS notions_reference, + sid.supplier_id AS supplier, + sid.notions_case_pack AS case_qty, + pc1.name AS brand, + p.company AS brand_id, + pc2.name AS line, + p.line AS line_id, + pc3.name AS subline, + p.subline AS subline_id, + pc4.name AS artist, + p.artist AS artist_id, + COALESCE(CASE + WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit + ELSE sid.supplier_qty_per_unit + END, sid.notions_qty_per_unit) AS moq, + p.weight, + p.length, + p.width, + p.height, + p.country_of_origin, + ci.totalsold AS total_sold, + p.datein AS first_received, + pls.date_sold AS date_last_sold, + IF(p.tax_code IS NULL, '', CAST(p.tax_code AS CHAR)) AS tax_code, + CAST(p.size_cat AS CHAR) AS size_cat, + CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions + FROM products p + LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1 + LEFT JOIN supplier_item_data sid ON p.pid = sid.pid + LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid + LEFT JOIN product_categories pc1 ON p.company = pc1.cat_id + LEFT JOIN product_categories pc2 ON p.line = pc2.cat_id + LEFT JOIN product_categories pc3 ON p.subline = pc3.cat_id + LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id + LEFT JOIN product_last_sold pls ON p.pid = pls.pid + LEFT JOIN current_inventory ci ON p.pid = ci.pid + ${whereClause} + GROUP BY p.pid + ${isWildcardSearch ? 'ORDER BY p.datein DESC' : ` + ORDER BY + CASE + WHEN p.description LIKE ? THEN 1 + WHEN p.itemnumber = ? THEN 2 + WHEN p.upc = ? THEN 3 + WHEN pc1.name LIKE ? THEN 4 + WHEN s.companyname LIKE ? THEN 5 + ELSE 6 + END + `} + `; + + // Prepare query parameters based on whether it's a wildcard search + let queryParams; + if (isWildcardSearch) { + queryParams = [ + searchPattern, // LIKE for description + searchPattern, // LIKE for itemnumber + searchPattern, // LIKE for upc + searchPattern, // LIKE for brand name + searchPattern // LIKE for company name + ]; + } else { + queryParams = [ + searchPattern, // LIKE for description + searchPattern, // LIKE for itemnumber + searchPattern, // LIKE for upc + searchPattern, // LIKE for brand name + searchPattern, // LIKE for company name + // For ORDER BY clause + searchPattern, // LIKE for description + exactPattern, // Exact match for itemnumber + exactPattern, // Exact match for upc + searchPattern, // LIKE for brand name + searchPattern // LIKE for company name + ]; + } + + const [results] = await connection.query(query, queryParams); + + // Debug log to check values + if (results.length > 0) { + console.log('Product search result sample fields:', { + pid: results[0].pid, + tax_code: results[0].tax_code, + tax_code_type: typeof results[0].tax_code, + tax_code_value: `Value: '${results[0].tax_code}'`, + size_cat: results[0].size_cat, + shipping_restrictions: results[0].shipping_restrictions, + supplier: results[0].supplier, + case_qty: results[0].case_qty, + moq: results[0].moq + }); + } + + res.json(results); + } catch (error) { + console.error('Error searching products:', error); + res.status(500).json({ error: 'Failed to search products' }); + } +}); + +// Endpoint to check UPC and generate item number +router.get('/check-upc-and-generate-sku', async (req, res) => { + const { upc, supplierId } = req.query; + + if (!upc || !supplierId) { + return res.status(400).json({ error: 'UPC and supplier ID are required' }); + } + + try { + const { connection } = await getDbConnection(); + + // Step 1: Check if the UPC already exists + const [upcCheck] = await connection.query( + 'SELECT pid, itemnumber FROM products WHERE upc = ? LIMIT 1', + [upc] + ); + + if (upcCheck.length > 0) { + return res.status(409).json({ + error: 'UPC already exists', + existingProductId: upcCheck[0].pid, + existingItemNumber: upcCheck[0].itemnumber + }); + } + + // Step 2: Generate item number - supplierId-last5DigitsOfUPC minus last digit + let itemNumber = ''; + const upcStr = String(upc); + + // Extract the last 5 digits of the UPC, removing the last digit (checksum) + // So we get 5 digits from positions: length-6 to length-2 + if (upcStr.length >= 6) { + const lastFiveMinusOne = upcStr.substring(upcStr.length - 6, upcStr.length - 1); + itemNumber = `${supplierId}-${lastFiveMinusOne}`; + } else if (upcStr.length >= 5) { + // If UPC is shorter, use as many digits as possible + const digitsToUse = upcStr.substring(0, upcStr.length - 1); + itemNumber = `${supplierId}-${digitsToUse}`; + } else { + // Very short UPC, just use the whole thing + itemNumber = `${supplierId}-${upcStr}`; + } + + // Step 3: Check if the generated item number exists + const [itemNumberCheck] = await connection.query( + 'SELECT pid FROM products WHERE itemnumber = ? LIMIT 1', + [itemNumber] + ); + + // Step 4: If the item number exists, modify it to use the last 5 digits of the UPC + if (itemNumberCheck.length > 0) { + console.log(`Item number ${itemNumber} already exists, using alternative format`); + + if (upcStr.length >= 5) { + // Use the last 5 digits (including the checksum) + const lastFive = upcStr.substring(upcStr.length - 5); + itemNumber = `${supplierId}-${lastFive}`; + + // Check again if this new item number also exists + const [altItemNumberCheck] = await connection.query( + 'SELECT pid FROM products WHERE itemnumber = ? LIMIT 1', + [itemNumber] + ); + + if (altItemNumberCheck.length > 0) { + // If even the alternative format exists, add a timestamp suffix for uniqueness + const timestamp = Date.now().toString().substring(8, 13); // Get last 5 digits of timestamp + itemNumber = `${supplierId}-${timestamp}`; + console.log(`Alternative item number also exists, using timestamp: ${itemNumber}`); + } + } else { + // For very short UPCs, add a timestamp + const timestamp = Date.now().toString().substring(8, 13); // Get last 5 digits of timestamp + itemNumber = `${supplierId}-${timestamp}`; + } + } + + // Return the generated item number + res.json({ + success: true, + itemNumber, + upc, + supplierId + }); + + } catch (error) { + console.error('Error checking UPC and generating item number:', error); + res.status(500).json({ + error: 'Failed to check UPC and generate item number', + details: error.message + }); + } +}); + +// Get product categories for a specific product +router.get('/product-categories/:pid', async (req, res) => { + try { + const { pid } = req.params; + + if (!pid || isNaN(parseInt(pid))) { + return res.status(400).json({ error: 'Valid product ID is required' }); + } + + // Use the getDbConnection function instead of getPool + const { connection } = await getDbConnection(); + + // Query to get categories for a specific product + const query = ` + SELECT pc.cat_id, pc.name, pc.type, pc.combined_name, pc.master_cat_id + FROM product_category_index pci + JOIN product_categories pc ON pci.cat_id = pc.cat_id + WHERE pci.pid = ? + ORDER BY pc.type, pc.name + `; + + const [rows] = await connection.query(query, [pid]); + + // Add debugging to log category types + const categoryTypes = rows.map(row => row.type); + const uniqueTypes = [...new Set(categoryTypes)]; + console.log(`Product ${pid} has ${rows.length} categories with types: ${uniqueTypes.join(', ')}`); + console.log('Categories:', rows.map(row => ({ id: row.cat_id, name: row.name, type: row.type }))); + + // Check for parent categories to filter out deals and black friday + const sectionQuery = ` + SELECT pc.cat_id, pc.name + FROM product_categories pc + WHERE pc.type = 10 AND (LOWER(pc.name) LIKE '%deal%' OR LOWER(pc.name) LIKE '%black friday%') + `; + + const [dealSections] = await connection.query(sectionQuery); + const dealSectionIds = dealSections.map(section => section.cat_id); + + console.log('Filtering out categories from deal sections:', dealSectionIds); + + // Filter out categories from deals and black friday sections + const filteredCategories = rows.filter(category => { + // Direct check for top-level deal sections + if (category.type === 10) { + return !dealSectionIds.some(id => id === category.cat_id); + } + + // For categories (type 11), check if their parent is a deal section + if (category.type === 11) { + return !dealSectionIds.some(id => id === category.master_cat_id); + } + + // For subcategories (type 12), get their parent category first + if (category.type === 12) { + const parentId = category.master_cat_id; + // Find the parent category in our rows + const parentCategory = rows.find(c => c.cat_id === parentId); + // If parent not found or parent's parent is not a deal section, keep it + return !parentCategory || !dealSectionIds.some(id => id === parentCategory.master_cat_id); + } + + // For subsubcategories (type 13), check their hierarchy manually + if (category.type === 13) { + const parentId = category.master_cat_id; + // Find the parent subcategory + const parentSubcategory = rows.find(c => c.cat_id === parentId); + if (!parentSubcategory) return true; + + // Find the grandparent category + const grandparentId = parentSubcategory.master_cat_id; + const grandparentCategory = rows.find(c => c.cat_id === grandparentId); + // If grandparent not found or grandparent's parent is not a deal section, keep it + return !grandparentCategory || !dealSectionIds.some(id => id === grandparentCategory.master_cat_id); + } + + // Keep all other category types + return true; + }); + + console.log(`Filtered out ${rows.length - filteredCategories.length} deal/black friday categories`); + + // Format the response to match the expected format in the frontend + const categories = filteredCategories.map(category => ({ + value: category.cat_id.toString(), + label: category.name, + type: category.type, + combined_name: category.combined_name + })); + + res.json(categories); + } catch (error) { + console.error('Error fetching product categories:', error); + res.status(500).json({ + error: 'Failed to fetch product categories', + details: error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/metrics.js b/inventory-server/src/routes/metrics.js new file mode 100644 index 0000000..01924fa --- /dev/null +++ b/inventory-server/src/routes/metrics.js @@ -0,0 +1,590 @@ +const express = require('express'); +const router = express.Router(); +const { Pool } = require('pg'); // Assuming pg driver + +// --- Configuration & Helpers --- + +const DEFAULT_PAGE_LIMIT = 50; +const MAX_PAGE_LIMIT = 200; // Prevent excessive data requests + +// Define direct mapping from frontend column names to database columns +// This simplifies the code by eliminating conversion logic +const COLUMN_MAP = { + // Product Info + pid: 'pm.pid', + sku: 'pm.sku', + title: 'pm.title', + brand: 'pm.brand', + vendor: 'pm.vendor', + imageUrl: 'pm.image_url', + isVisible: 'pm.is_visible', + isReplenishable: 'pm.is_replenishable', + // Additional Product Fields + barcode: 'pm.barcode', + harmonizedTariffCode: 'pm.harmonized_tariff_code', + vendorReference: 'pm.vendor_reference', + notionsReference: 'pm.notions_reference', + line: 'pm.line', + subline: 'pm.subline', + artist: 'pm.artist', + moq: 'pm.moq', + rating: 'pm.rating', + reviews: 'pm.reviews', + weight: 'pm.weight', + length: 'pm.length', + width: 'pm.width', + height: 'pm.height', + countryOfOrigin: 'pm.country_of_origin', + location: 'pm.location', + baskets: 'pm.baskets', + notifies: 'pm.notifies', + preorderCount: 'pm.preorder_count', + notionsInvCount: 'pm.notions_inv_count', + // Current Status + currentPrice: 'pm.current_price', + currentRegularPrice: 'pm.current_regular_price', + currentCostPrice: 'pm.current_cost_price', + currentLandingCostPrice: 'pm.current_landing_cost_price', + currentStock: 'pm.current_stock', + currentStockCost: 'pm.current_stock_cost', + currentStockRetail: 'pm.current_stock_retail', + currentStockGross: 'pm.current_stock_gross', + onOrderQty: 'pm.on_order_qty', + onOrderCost: 'pm.on_order_cost', + onOrderRetail: 'pm.on_order_retail', + earliestExpectedDate: 'pm.earliest_expected_date', + // Historical Dates + dateCreated: 'pm.date_created', + dateFirstReceived: 'pm.date_first_received', + dateLastReceived: 'pm.date_last_received', + dateFirstSold: 'pm.date_first_sold', + dateLastSold: 'pm.date_last_sold', + ageDays: 'pm.age_days', + // Rolling Period Metrics + sales7d: 'pm.sales_7d', + revenue7d: 'pm.revenue_7d', + sales14d: 'pm.sales_14d', + revenue14d: 'pm.revenue_14d', + sales30d: 'pm.sales_30d', + revenue30d: 'pm.revenue_30d', + cogs30d: 'pm.cogs_30d', + profit30d: 'pm.profit_30d', + returnsUnits30d: 'pm.returns_units_30d', + returnsRevenue30d: 'pm.returns_revenue_30d', + discounts30d: 'pm.discounts_30d', + grossRevenue30d: 'pm.gross_revenue_30d', + grossRegularRevenue30d: 'pm.gross_regular_revenue_30d', + stockoutDays30d: 'pm.stockout_days_30d', + sales365d: 'pm.sales_365d', + revenue365d: 'pm.revenue_365d', + avgStockUnits30d: 'pm.avg_stock_units_30d', + avgStockCost30d: 'pm.avg_stock_cost_30d', + avgStockRetail30d: 'pm.avg_stock_retail_30d', + avgStockGross30d: 'pm.avg_stock_gross_30d', + receivedQty30d: 'pm.received_qty_30d', + receivedCost30d: 'pm.received_cost_30d', + // Lifetime Metrics + lifetimeSales: 'pm.lifetime_sales', + lifetimeRevenue: 'pm.lifetime_revenue', + // First Period Metrics + first7DaysSales: 'pm.first_7_days_sales', + first7DaysRevenue: 'pm.first_7_days_revenue', + first30DaysSales: 'pm.first_30_days_sales', + first30DaysRevenue: 'pm.first_30_days_revenue', + first60DaysSales: 'pm.first_60_days_sales', + first60DaysRevenue: 'pm.first_60_days_revenue', + first90DaysSales: 'pm.first_90_days_sales', + first90DaysRevenue: 'pm.first_90_days_revenue', + // Calculated KPIs + asp30d: 'pm.asp_30d', + acp30d: 'pm.acp_30d', + avgRos30d: 'pm.avg_ros_30d', + avgSalesPerDay30d: 'pm.avg_sales_per_day_30d', + avgSalesPerMonth30d: 'pm.avg_sales_per_month_30d', + margin30d: 'pm.margin_30d', + markup30d: 'pm.markup_30d', + gmroi30d: 'pm.gmroi_30d', + stockturn30d: 'pm.stockturn_30d', + returnRate30d: 'pm.return_rate_30d', + discountRate30d: 'pm.discount_rate_30d', + stockoutRate30d: 'pm.stockout_rate_30d', + markdown30d: 'pm.markdown_30d', + markdownRate30d: 'pm.markdown_rate_30d', + sellThrough30d: 'pm.sell_through_30d', + avgLeadTimeDays: 'pm.avg_lead_time_days', + // Forecasting & Replenishment + abcClass: 'pm.abc_class', + salesVelocityDaily: 'pm.sales_velocity_daily', + configLeadTime: 'pm.config_lead_time', + configDaysOfStock: 'pm.config_days_of_stock', + configSafetyStock: 'pm.config_safety_stock', + planningPeriodDays: 'pm.planning_period_days', + leadTimeForecastUnits: 'pm.lead_time_forecast_units', + daysOfStockForecastUnits: 'pm.days_of_stock_forecast_units', + planningPeriodForecastUnits: 'pm.planning_period_forecast_units', + leadTimeClosingStock: 'pm.lead_time_closing_stock', + daysOfStockClosingStock: 'pm.days_of_stock_closing_stock', + replenishmentNeededRaw: 'pm.replenishment_needed_raw', + replenishmentUnits: 'pm.replenishment_units', + replenishmentCost: 'pm.replenishment_cost', + replenishmentRetail: 'pm.replenishment_retail', + replenishmentProfit: 'pm.replenishment_profit', + toOrderUnits: 'pm.to_order_units', + forecastLostSalesUnits: 'pm.forecast_lost_sales_units', + forecastLostRevenue: 'pm.forecast_lost_revenue', + stockCoverInDays: 'pm.stock_cover_in_days', + poCoverInDays: 'pm.po_cover_in_days', + sellsOutInDays: 'pm.sells_out_in_days', + replenishDate: 'pm.replenish_date', + overstockedUnits: 'pm.overstocked_units', + overstockedCost: 'pm.overstocked_cost', + overstockedRetail: 'pm.overstocked_retail', + isOldStock: 'pm.is_old_stock', + // Yesterday + yesterdaySales: 'pm.yesterday_sales', + // Map status column - directly mapped now instead of calculated on frontend + status: 'pm.status', + + // Growth Metrics (P3) + salesGrowth30dVsPrev: 'pm.sales_growth_30d_vs_prev', + revenueGrowth30dVsPrev: 'pm.revenue_growth_30d_vs_prev', + salesGrowthYoy: 'pm.sales_growth_yoy', + revenueGrowthYoy: 'pm.revenue_growth_yoy', + + // Demand Variability Metrics (P3) + salesVariance30d: 'pm.sales_variance_30d', + salesStdDev30d: 'pm.sales_std_dev_30d', + salesCv30d: 'pm.sales_cv_30d', + demandPattern: 'pm.demand_pattern', + + // Service Level Metrics (P5) + fillRate30d: 'pm.fill_rate_30d', + stockoutIncidents30d: 'pm.stockout_incidents_30d', + serviceLevel30d: 'pm.service_level_30d', + lostSalesIncidents30d: 'pm.lost_sales_incidents_30d', + + // Seasonality Metrics (P5) + seasonalityIndex: 'pm.seasonality_index', + seasonalPattern: 'pm.seasonal_pattern', + peakSeason: 'pm.peak_season', + + // Lifetime Revenue Quality + lifetimeRevenueQuality: 'pm.lifetime_revenue_quality' +}; + +// Define column types for use in sorting/filtering +// This helps apply correct comparison operators and sorting logic +const COLUMN_TYPES = { + // Numeric columns (use numeric operators and sorting) + numeric: [ + 'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentLandingCostPrice', + 'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross', + 'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays', + 'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d', + 'cogs30d', 'profit30d', 'returnsUnits30d', 'returnsRevenue30d', 'discounts30d', + 'grossRevenue30d', 'grossRegularRevenue30d', 'stockoutDays30d', 'sales365d', 'revenue365d', + 'avgStockUnits30d', 'avgStockCost30d', 'avgStockRetail30d', 'avgStockGross30d', + 'receivedQty30d', 'receivedCost30d', 'lifetimeSales', 'lifetimeRevenue', + 'first7DaysSales', 'first7DaysRevenue', 'first30DaysSales', 'first30DaysRevenue', + 'first60DaysSales', 'first60DaysRevenue', 'first90DaysSales', 'first90DaysRevenue', + 'asp30d', 'acp30d', 'avgRos30d', 'avgSalesPerDay30d', 'avgSalesPerMonth30d', + 'margin30d', 'markup30d', 'gmroi30d', 'stockturn30d', 'returnRate30d', 'discountRate30d', + 'stockoutRate30d', 'markdown30d', 'markdownRate30d', 'sellThrough30d', 'avgLeadTimeDays', + 'salesVelocityDaily', 'configLeadTime', 'configDaysOfStock', 'configSafetyStock', + 'planningPeriodDays', 'leadTimeForecastUnits', 'daysOfStockForecastUnits', + 'planningPeriodForecastUnits', 'leadTimeClosingStock', 'daysOfStockClosingStock', + 'replenishmentNeededRaw', 'replenishmentUnits', 'replenishmentCost', 'replenishmentRetail', + 'replenishmentProfit', 'toOrderUnits', 'forecastLostSalesUnits', 'forecastLostRevenue', + 'stockCoverInDays', 'poCoverInDays', 'sellsOutInDays', 'overstockedUnits', + 'overstockedCost', 'overstockedRetail', 'yesterdaySales', + // New numeric columns + 'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height', + 'baskets', 'notifies', 'preorderCount', 'notionsInvCount', + // Growth metrics + 'salesGrowth30dVsPrev', 'revenueGrowth30dVsPrev', 'salesGrowthYoy', 'revenueGrowthYoy', + // Demand variability metrics + 'salesVariance30d', 'salesStdDev30d', 'salesCv30d', + // Service level metrics + 'fillRate30d', 'stockoutIncidents30d', 'serviceLevel30d', 'lostSalesIncidents30d', + // Seasonality metrics + 'seasonalityIndex' + ], + // Date columns (use date operators and sorting) + date: [ + 'dateCreated', 'dateFirstReceived', 'dateLastReceived', 'dateFirstSold', 'dateLastSold', + 'earliestExpectedDate', 'replenishDate', 'forecastedOutOfStockDate' + ], + // String columns (use string operators and sorting) + string: [ + 'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status', + // New string columns + 'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference', + 'line', 'subline', 'artist', 'countryOfOrigin', 'location', + // New string columns for patterns + 'demandPattern', 'seasonalPattern', 'peakSeason', 'lifetimeRevenueQuality' + ], + // Boolean columns (use boolean operators and sorting) + boolean: ['isVisible', 'isReplenishable', 'isOldStock'] +}; + +// Special sort handling for certain columns +const SPECIAL_SORT_COLUMNS = { + // Percentage columns where we want to sort by the numeric value + margin30d: true, + markup30d: true, + sellThrough30d: true, + discountRate30d: true, + stockoutRate30d: true, + returnRate30d: true, + markdownRate30d: true, + + // Columns where we may want to sort by absolute value + profit30d: 'abs', + + // Velocity columns + salesVelocityDaily: true, + + // Growth rate columns + salesGrowth30dVsPrev: 'abs', + revenueGrowth30dVsPrev: 'abs', + salesGrowthYoy: 'abs', + revenueGrowthYoy: 'abs', + + // Status column needs special ordering + status: 'priority' +}; + +// Status priority for sorting (lower number = higher priority) +const STATUS_PRIORITY = { + 'Critical': 1, + 'At Risk': 2, + 'Reorder': 3, + 'Overstocked': 4, + 'Healthy': 5, + 'New': 6 + // Any other status will be sorted alphabetically after these +}; + +// Get database column name from frontend column name +function getDbColumn(frontendColumn) { + return COLUMN_MAP[frontendColumn] || 'pm.title'; // Default to title if not found +} + +// Get column type for proper sorting +function getColumnType(frontendColumn) { + return COLUMN_TYPES[frontendColumn] || 'string'; +} + +// --- Route Handlers --- + +// GET /metrics/filter-options - Provide distinct values for filter dropdowns +router.get('/filter-options', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /metrics/filter-options'); + try { + const [vendorRes, brandRes, abcClassRes] = await Promise.all([ + pool.query(`SELECT DISTINCT vendor FROM public.product_metrics WHERE vendor IS NOT NULL AND vendor <> '' ORDER BY vendor`), + pool.query(`SELECT DISTINCT COALESCE(brand, 'Unbranded') as brand FROM public.product_metrics WHERE brand IS NOT NULL AND brand <> '' ORDER BY brand`), + pool.query(`SELECT DISTINCT abc_class FROM public.product_metrics WHERE abc_class IS NOT NULL ORDER BY abc_class`) + // Add queries for other distinct options if needed (e.g., categories if stored on pm) + ]); + + res.json({ + vendors: vendorRes.rows.map(r => r.vendor), + brands: brandRes.rows.map(r => r.brand), + abcClasses: abcClassRes.rows.map(r => r.abc_class), + }); + } catch (error) { + console.error('Error fetching filter options:', error); + res.status(500).json({ error: 'Failed to fetch filter options' }); + } +}); + + +// GET /metrics/ - List all product metrics with filtering, sorting, pagination +router.get('/', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /metrics received query:', req.query); + + try { + // --- Pagination --- + let page = parseInt(req.query.page, 10); + let limit = parseInt(req.query.limit, 10); + if (isNaN(page) || page < 1) page = 1; + if (isNaN(limit) || limit < 1) limit = DEFAULT_PAGE_LIMIT; + limit = Math.min(limit, MAX_PAGE_LIMIT); // Cap the limit + const offset = (page - 1) * limit; + + // --- Sorting --- + const sortQueryKey = req.query.sort || 'title'; // Default sort field key + const dbColumn = getDbColumn(sortQueryKey); + const columnType = getColumnType(sortQueryKey); + + console.log(`Sorting request: ${sortQueryKey} -> ${dbColumn} (${columnType})`); + + const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + + // Always put nulls last regardless of sort direction or column type + const nullsOrder = 'NULLS LAST'; + + // Build the ORDER BY clause based on column type and special handling + let orderByClause; + + if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'abs') { + // Sort by absolute value for columns where negative values matter + orderByClause = `ABS(${dbColumn}::numeric) ${sortDirection} ${nullsOrder}`; + } else if (columnType === 'number' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) { + // For numeric columns, cast to numeric to ensure proper sorting + orderByClause = `${dbColumn}::numeric ${sortDirection} ${nullsOrder}`; + } else if (columnType === 'date') { + // For date columns, cast to timestamp to ensure proper sorting + orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn}::timestamp ${sortDirection}`; + } else if (columnType === 'status' || SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') { + // Special handling for status column, using priority for known statuses + orderByClause = ` + CASE WHEN ${dbColumn} IS NULL THEN 999 + WHEN ${dbColumn} = 'Critical' THEN 1 + WHEN ${dbColumn} = 'At Risk' THEN 2 + WHEN ${dbColumn} = 'Reorder' THEN 3 + WHEN ${dbColumn} = 'Overstocked' THEN 4 + WHEN ${dbColumn} = 'Healthy' THEN 5 + WHEN ${dbColumn} = 'New' THEN 6 + ELSE 100 + END ${sortDirection} ${nullsOrder}, + ${dbColumn} ${sortDirection}`; + } else { + // For string and boolean columns, no special casting needed + orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn} ${sortDirection}`; + } + + // --- Filtering --- + const conditions = []; + const params = []; + let paramCounter = 1; + + // Add default visibility/replenishable filters unless overridden + if (req.query.showInvisible !== 'true') conditions.push(`pm.is_visible = true`); + if (req.query.showNonReplenishable !== 'true') conditions.push(`pm.is_replenishable = true`); + + // Special handling for stock_status + if (req.query.stock_status) { + const status = req.query.stock_status; + // Handle special case for "at-risk" which is stored as "At Risk" in the database + if (status.toLowerCase() === 'at-risk') { + conditions.push(`pm.status = $${paramCounter++}`); + params.push('At Risk'); + } else { + // Capitalize first letter to match database values + conditions.push(`pm.status = $${paramCounter++}`); + params.push(status.charAt(0).toUpperCase() + status.slice(1)); + } + } + + // Process other filters from query parameters + for (const key in req.query) { + // Skip control params + if (['page', 'limit', 'sort', 'order', 'showInvisible', 'showNonReplenishable', 'stock_status'].includes(key)) continue; + + let filterKey = key; + let operator = '='; // Default operator + let value = req.query[key]; + + // Check for operator suffixes (e.g., sales30d_gt, title_like) + const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/); + if (operatorMatch) { + filterKey = operatorMatch[1]; // e.g., "sales30d" + operator = operatorMatch[2]; // e.g., "gt" + } + + // Get the database column for this filter key + const dbColumn = getDbColumn(filterKey); + const valueType = getColumnType(filterKey); + + if (!dbColumn) { + console.warn(`Invalid filter key ignored: ${key}`); + continue; // Skip if the key doesn't map to a known column + } + + // --- Build WHERE clause fragment --- + try { + let conditionFragment = ''; + let needsParam = true; // Most operators need a parameter + + switch (operator.toLowerCase()) { + case 'eq': operator = '='; break; + case 'ne': operator = '<>'; break; + case 'gt': operator = '>'; break; + case 'gte': operator = '>='; break; + case 'lt': operator = '<'; break; + case 'lte': operator = '<='; break; + case 'like': operator = 'LIKE'; value = `%${value}%`; break; // Add wildcards for LIKE + case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break; // Add wildcards for ILIKE + case 'between': + const [val1, val2] = String(value).split(','); + if (val1 !== undefined && val2 !== undefined) { + conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`; + params.push(parseValue(val1, valueType), parseValue(val2, valueType)); + needsParam = false; // Params added manually + } else { + console.warn(`Invalid 'between' value for ${key}: ${value}`); + continue; // Skip this filter + } + break; + case 'in': + const inValues = String(value).split(','); + if (inValues.length > 0) { + const placeholders = inValues.map(() => `$${paramCounter++}`).join(', '); + conditionFragment = `${dbColumn} IN (${placeholders})`; + params.push(...inValues.map(v => parseValue(v, valueType))); // Add all parsed values + needsParam = false; // Params added manually + } else { + console.warn(`Invalid 'in' value for ${key}: ${value}`); + continue; // Skip this filter + } + break; + // Add other operators as needed (IS NULL, IS NOT NULL, etc.) + case '=': // Keep default '=' + default: operator = '='; break; // Ensure default is handled + } + + if (needsParam) { + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; + params.push(parseValue(value, valueType)); + } + + if (conditionFragment) { + conditions.push(`(${conditionFragment})`); // Wrap condition in parentheses + } + + } catch (parseError) { + console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`); + // Decrement counter if param wasn't actually used due to error + if (needsParam) paramCounter--; + } + } + + // --- Construct and Execute Queries --- + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Debug log of conditions and parameters + console.log('Constructed WHERE conditions:', conditions); + console.log('Parameters:', params); + + // Count Query + const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`; + console.log('Executing Count Query:', countSql, params); + const countPromise = pool.query(countSql, params); + + // Data Query (Select all columns from metrics table for now) + const dataSql = ` + SELECT pm.* + FROM public.product_metrics pm + ${whereClause} + ORDER BY ${orderByClause} + LIMIT $${paramCounter} OFFSET $${paramCounter + 1} + `; + const dataParams = [...params, limit, offset]; + + // Log detailed query information for debugging + console.log('Executing Data Query:'); + console.log(' - Sort Column:', dbColumn); + console.log(' - Column Type:', columnType); + console.log(' - Sort Direction:', sortDirection); + console.log(' - Order By Clause:', orderByClause); + console.log(' - Full SQL:', dataSql); + console.log(' - Parameters:', dataParams); + + const dataPromise = pool.query(dataSql, dataParams); + + // Execute queries in parallel + const [countResult, dataResult] = await Promise.all([countPromise, dataPromise]); + + const total = parseInt(countResult.rows[0].total, 10); + const metrics = dataResult.rows; + console.log(`Total: ${total}, Fetched: ${metrics.length} for page ${page}`); + + // --- Respond --- + res.json({ + metrics, + pagination: { + total, + pages: Math.ceil(total / limit), + currentPage: page, + limit, + }, + // Optionally include applied filters/sort for frontend confirmation + appliedQuery: { + filters: req.query, // Send back raw query filters + sort: sortQueryKey, + order: sortDirection.toLowerCase() + } + }); + + } catch (error) { + console.error('Error fetching metrics list:', error); + res.status(500).json({ error: 'Failed to fetch product metrics list.' }); + } +}); + +// GET /metrics/:pid - Get metrics for a single product +router.get('/:pid', async (req, res) => { + const pool = req.app.locals.pool; + const pid = parseInt(req.params.pid, 10); + + if (isNaN(pid)) { + return res.status(400).json({ error: 'Invalid Product ID.' }); + } + + console.log(`GET /metrics/${pid}`); + try { + const { rows } = await pool.query( + `SELECT * FROM public.product_metrics WHERE pid = $1`, + [pid] + ); + + if (rows.length === 0) { + console.log(`Metrics not found for PID: ${pid}`); + return res.status(404).json({ error: 'Metrics not found for this product.' }); + } + + console.log(`Metrics found for PID: ${pid}`); + // Data is pre-calculated, return the first (only) row + res.json(rows[0]); + + } catch (error) { + console.error(`Error fetching metrics for PID ${pid}:`, error); + res.status(500).json({ error: 'Failed to fetch product metrics.' }); + } +}); + + +/** + * Parses a value based on its expected type. + * Throws error for invalid formats. + */ +function parseValue(value, type) { + if (value === null || value === undefined || value === '') return null; // Allow empty strings? Or handle differently? + + switch (type) { + case 'number': + const num = parseFloat(value); + if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`); + return num; + case 'boolean': + if (String(value).toLowerCase() === 'true') return true; + if (String(value).toLowerCase() === 'false') return false; + throw new Error(`Invalid boolean format: "${value}"`); + case 'date': + // Basic validation, rely on DB to handle actual date conversion + if (!String(value).match(/^\d{4}-\d{2}-\d{2}$/)) { + // Allow full timestamps too? Adjust regex if needed + // console.warn(`Potentially invalid date format: "${value}"`); // Warn instead of throwing? + } + return String(value); // Send as string, let DB handle it + case 'string': + default: + return String(value); + } +} + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/orders.js b/inventory-server/src/routes/orders.js new file mode 100644 index 0000000..b109111 --- /dev/null +++ b/inventory-server/src/routes/orders.js @@ -0,0 +1,261 @@ +const express = require('express'); +const router = express.Router(); + +// Get all orders with pagination, filtering, and sorting +router.get('/', async (req, res) => { + const pool = req.app.locals.pool; + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 50; + const offset = (page - 1) * limit; + const search = req.query.search || ''; + const status = req.query.status || 'all'; + const fromDate = req.query.fromDate ? new Date(req.query.fromDate) : null; + const toDate = req.query.toDate ? new Date(req.query.toDate) : null; + const minAmount = parseFloat(req.query.minAmount) || 0; + const maxAmount = req.query.maxAmount ? parseFloat(req.query.maxAmount) : null; + const sortColumn = req.query.sortColumn || 'date'; + const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC'; + + // Build the WHERE clause + const conditions = ['o1.canceled = false']; + const params = []; + let paramCounter = 1; + + if (search) { + conditions.push(`(o1.order_number ILIKE $${paramCounter} OR o1.customer ILIKE $${paramCounter})`); + params.push(`%${search}%`); + paramCounter++; + } + + if (status !== 'all') { + conditions.push(`o1.status = $${paramCounter}`); + params.push(status); + paramCounter++; + } + + if (fromDate) { + conditions.push(`DATE(o1.date) >= DATE($${paramCounter})`); + params.push(fromDate.toISOString()); + paramCounter++; + } + + if (toDate) { + conditions.push(`DATE(o1.date) <= DATE($${paramCounter})`); + params.push(toDate.toISOString()); + paramCounter++; + } + + if (minAmount > 0) { + conditions.push(`total_amount >= $${paramCounter}`); + params.push(minAmount); + paramCounter++; + } + + if (maxAmount) { + conditions.push(`total_amount <= $${paramCounter}`); + params.push(maxAmount); + paramCounter++; + } + + // Get total count for pagination + const { rows: [countResult] } = await pool.query(` + SELECT COUNT(DISTINCT o1.order_number) as total + FROM orders o1 + LEFT JOIN ( + SELECT order_number, SUM(price * quantity) as total_amount + FROM orders + GROUP BY order_number + ) totals ON o1.order_number = totals.order_number + WHERE ${conditions.join(' AND ')} + `, params); + + const total = countResult.total; + + // Get paginated results + const query = ` + SELECT + o1.order_number, + o1.customer, + o1.date, + o1.status, + o1.payment_method, + o1.shipping_method, + COUNT(o2.pid) as items_count, + ROUND(SUM(o2.price * o2.quantity)::numeric, 3) as total_amount + FROM orders o1 + JOIN orders o2 ON o1.order_number = o2.order_number + WHERE ${conditions.join(' AND ')} + GROUP BY + o1.order_number, + o1.customer, + o1.date, + o1.status, + o1.payment_method, + o1.shipping_method + ORDER BY ${ + sortColumn === 'items_count' || sortColumn === 'total_amount' + ? `${sortColumn} ${sortDirection}` + : `o1.${sortColumn} ${sortDirection}` + } + LIMIT $${paramCounter} OFFSET $${paramCounter + 1} + `; + + params.push(limit, offset); + const { rows } = await pool.query(query, params); + + // Get order statistics + const { rows: [orderStats] } = await pool.query(` + WITH CurrentStats AS ( + SELECT + COUNT(DISTINCT order_number) as total_orders, + ROUND(SUM(price * quantity)::numeric, 3) as total_revenue + FROM orders + WHERE canceled = false + AND DATE(date) >= CURRENT_DATE - INTERVAL '30 days' + ), + PreviousStats AS ( + SELECT + COUNT(DISTINCT order_number) as prev_orders, + ROUND(SUM(price * quantity)::numeric, 3) as prev_revenue + FROM orders + WHERE canceled = false + AND DATE(date) BETWEEN CURRENT_DATE - INTERVAL '60 days' AND CURRENT_DATE - INTERVAL '30 days' + ), + OrderValues AS ( + SELECT + order_number, + ROUND(SUM(price * quantity)::numeric, 3) as order_value + FROM orders + WHERE canceled = false + AND DATE(date) >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY order_number + ) + SELECT + cs.total_orders, + cs.total_revenue, + CASE + WHEN ps.prev_orders > 0 + THEN ROUND(((cs.total_orders - ps.prev_orders)::numeric / ps.prev_orders * 100), 1) + ELSE 0 + END as order_growth, + CASE + WHEN ps.prev_revenue > 0 + THEN ROUND(((cs.total_revenue - ps.prev_revenue)::numeric / ps.prev_revenue * 100), 1) + ELSE 0 + END as revenue_growth, + CASE + WHEN cs.total_orders > 0 + THEN ROUND((cs.total_revenue::numeric / cs.total_orders), 3) + ELSE 0 + END as average_order_value, + CASE + WHEN ps.prev_orders > 0 + THEN ROUND((ps.prev_revenue::numeric / ps.prev_orders), 3) + ELSE 0 + END as prev_average_order_value + FROM CurrentStats cs + CROSS JOIN PreviousStats ps + `); + + res.json({ + orders: rows.map(row => ({ + ...row, + total_amount: parseFloat(row.total_amount) || 0, + items_count: parseInt(row.items_count) || 0, + date: row.date + })), + pagination: { + total, + pages: Math.ceil(total / limit), + currentPage: page, + limit + }, + stats: { + totalOrders: parseInt(orderStats.total_orders) || 0, + totalRevenue: parseFloat(orderStats.total_revenue) || 0, + orderGrowth: parseFloat(orderStats.order_growth) || 0, + revenueGrowth: parseFloat(orderStats.revenue_growth) || 0, + averageOrderValue: parseFloat(orderStats.average_order_value) || 0, + aovGrowth: orderStats.prev_average_order_value > 0 + ? ((orderStats.average_order_value - orderStats.prev_average_order_value) / orderStats.prev_average_order_value * 100) + : 0, + conversionRate: 2.5, // Placeholder - would need actual visitor data + conversionGrowth: 0.5 // Placeholder - would need actual visitor data + } + }); + } catch (error) { + console.error('Error fetching orders:', error); + res.status(500).json({ error: 'Failed to fetch orders' }); + } +}); + +// Get a single order with its items +router.get('/:orderNumber', async (req, res) => { + const pool = req.app.locals.pool; + try { + // Get order details + const { rows: orderRows } = await pool.query(` + SELECT DISTINCT + o1.order_number, + o1.customer, + o1.date, + o1.status, + o1.payment_method, + o1.shipping_method, + o1.shipping_address, + o1.billing_address, + COUNT(o2.pid) as items_count, + ROUND(SUM(o2.price * o2.quantity)::numeric, 3) as total_amount + FROM orders o1 + JOIN orders o2 ON o1.order_number = o2.order_number + WHERE o1.order_number = $1 AND o1.canceled = false + GROUP BY + o1.order_number, + o1.customer, + o1.date, + o1.status, + o1.payment_method, + o1.shipping_method, + o1.shipping_address, + o1.billing_address + `, [req.params.orderNumber]); + + if (orderRows.length === 0) { + return res.status(404).json({ error: 'Order not found' }); + } + + // Get order items + const { rows: itemRows } = await pool.query(` + SELECT + o.pid, + p.title, + p.SKU, + o.quantity, + o.price, + ROUND((o.price * o.quantity)::numeric, 3) as total + FROM orders o + JOIN products p ON o.pid = p.pid + WHERE o.order_number = $1 AND o.canceled = false + `, [req.params.orderNumber]); + + const order = { + ...orderRows[0], + total_amount: parseFloat(orderRows[0].total_amount) || 0, + items_count: parseInt(orderRows[0].items_count) || 0, + items: itemRows.map(item => ({ + ...item, + price: parseFloat(item.price) || 0, + total: parseFloat(item.total) || 0, + quantity: parseInt(item.quantity) || 0 + })) + }; + + res.json(order); + } catch (error) { + console.error('Error fetching order:', error); + res.status(500).json({ error: 'Failed to fetch order' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js new file mode 100644 index 0000000..1fa3827 --- /dev/null +++ b/inventory-server/src/routes/products.js @@ -0,0 +1,747 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { importProductsFromCSV } = require('../utils/csvImporter'); +const { PurchaseOrderStatus, ReceivingStatus } = require('../types/status-codes'); + +// Configure multer for file uploads without silent fallbacks +const configuredUploadsDir = process.env.UPLOADS_DIR; +const uploadsDir = configuredUploadsDir + ? (path.isAbsolute(configuredUploadsDir) + ? configuredUploadsDir + : path.resolve(__dirname, '../../', configuredUploadsDir)) + : path.resolve(__dirname, '../../uploads'); + +try { + fs.mkdirSync(uploadsDir, { recursive: true }); +} catch (error) { + console.error(`Failed to initialize uploads directory at ${uploadsDir}:`, error); + throw error; +} + +const upload = multer({ dest: uploadsDir }); + +// Get unique brands +router.get('/brands', async (req, res) => { + console.log('Brands endpoint hit:', { + url: req.url, + method: req.method, + headers: req.headers, + path: req.path + }); + + try { + const pool = req.app.locals.pool; + console.log('Fetching brands from database...'); + + const { rows } = await pool.query(` + SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand + FROM products p + WHERE p.visible = true + ORDER BY COALESCE(p.brand, 'Unbranded') + `); + + console.log(`Found ${rows.length} brands:`, rows.slice(0, 3)); + res.json(rows.map(r => r.brand)); + } catch (error) { + console.error('Error fetching brands:', error); + res.status(500).json({ error: 'Failed to fetch brands' }); + } +}); + +// Get all products with pagination, filtering, and sorting +router.get('/', async (req, res) => { + const pool = req.app.locals.pool; + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 50; + const offset = (page - 1) * limit; + const sortColumn = req.query.sort || 'title'; + const sortDirection = req.query.order === 'desc' ? 'DESC' : 'ASC'; + + const conditions = ['p.visible = true']; + const params = []; + let paramCounter = 1; + + // Add default replenishable filter unless explicitly showing non-replenishable + if (req.query.showNonReplenishable !== 'true') { + conditions.push('p.replenishable = true'); + } + + // Handle search filter + if (req.query.search) { + conditions.push(`(p.title ILIKE $${paramCounter} OR p.SKU ILIKE $${paramCounter} OR p.barcode ILIKE $${paramCounter})`); + const searchTerm = `%${req.query.search}%`; + params.push(searchTerm); + paramCounter++; + } + + // Handle text filters for specific fields + if (req.query.barcode) { + conditions.push(`p.barcode ILIKE $${paramCounter}`); + params.push(`%${req.query.barcode}%`); + paramCounter++; + } + + if (req.query.vendor_reference) { + conditions.push(`p.vendor_reference ILIKE $${paramCounter}`); + params.push(`%${req.query.vendor_reference}%`); + paramCounter++; + } + + // Add new text filters for the additional fields + if (req.query.description) { + conditions.push(`p.description ILIKE $${paramCounter}`); + params.push(`%${req.query.description}%`); + paramCounter++; + } + + if (req.query.harmonized_tariff_code) { + conditions.push(`p.harmonized_tariff_code ILIKE $${paramCounter}`); + params.push(`%${req.query.harmonized_tariff_code}%`); + paramCounter++; + } + + if (req.query.notions_reference) { + conditions.push(`p.notions_reference ILIKE $${paramCounter}`); + params.push(`%${req.query.notions_reference}%`); + paramCounter++; + } + + if (req.query.line) { + conditions.push(`p.line ILIKE $${paramCounter}`); + params.push(`%${req.query.line}%`); + paramCounter++; + } + + if (req.query.subline) { + conditions.push(`p.subline ILIKE $${paramCounter}`); + params.push(`%${req.query.subline}%`); + paramCounter++; + } + + if (req.query.artist) { + conditions.push(`p.artist ILIKE $${paramCounter}`); + params.push(`%${req.query.artist}%`); + paramCounter++; + } + + if (req.query.country_of_origin) { + conditions.push(`p.country_of_origin ILIKE $${paramCounter}`); + params.push(`%${req.query.country_of_origin}%`); + paramCounter++; + } + + if (req.query.location) { + conditions.push(`p.location ILIKE $${paramCounter}`); + params.push(`%${req.query.location}%`); + paramCounter++; + } + + // Handle numeric filters with operators + const numericFields = { + stock: 'p.stock_quantity', + price: 'p.price', + costPrice: 'p.cost_price', + landingCost: 'p.landing_cost_price', + dailySalesAvg: 'pm.daily_sales_avg', + weeklySalesAvg: 'pm.weekly_sales_avg', + monthlySalesAvg: 'pm.monthly_sales_avg', + avgQuantityPerOrder: 'pm.avg_quantity_per_order', + numberOfOrders: 'pm.number_of_orders', + margin: 'pm.avg_margin_percent', + gmroi: 'pm.gmroi', + inventoryValue: 'pm.inventory_value', + costOfGoodsSold: 'pm.cost_of_goods_sold', + grossProfit: 'pm.gross_profit', + turnoverRate: 'pm.turnover_rate', + leadTime: 'pm.current_lead_time', + currentLeadTime: 'pm.current_lead_time', + targetLeadTime: 'pm.target_lead_time', + stockCoverage: 'pm.days_of_inventory', + daysOfStock: 'pm.days_of_inventory', + weeksOfStock: 'pm.weeks_of_inventory', + reorderPoint: 'pm.reorder_point', + safetyStock: 'pm.safety_stock', + // Add new numeric fields + preorderCount: 'p.preorder_count', + notionsInvCount: 'p.notions_inv_count', + rating: 'p.rating', + reviews: 'p.reviews', + weight: 'p.weight', + totalSold: 'p.total_sold', + baskets: 'p.baskets', + notifies: 'p.notifies' + }; + + Object.entries(req.query).forEach(([key, value]) => { + const field = numericFields[key]; + if (field) { + const operator = req.query[`${key}_operator`] || '='; + if (operator === 'between') { + try { + const [min, max] = JSON.parse(value); + conditions.push(`${field} BETWEEN $${paramCounter} AND $${paramCounter + 1}`); + params.push(min, max); + paramCounter += 2; + } catch (e) { + console.error(`Invalid between value for ${key}:`, value); + } + } else { + conditions.push(`${field} ${operator} $${paramCounter}`); + params.push(parseFloat(value)); + paramCounter++; + } + } + }); + + // Handle date filters + const dateFields = { + firstSaleDate: 'pm.first_sale_date', + lastSaleDate: 'pm.last_sale_date', + lastPurchaseDate: 'pm.last_purchase_date', + firstReceivedDate: 'pm.first_received_date', + lastReceivedDate: 'pm.last_received_date' + }; + + Object.entries(req.query).forEach(([key, value]) => { + const field = dateFields[key]; + if (field) { + conditions.push(`${field}::TEXT LIKE $${paramCounter}`); + params.push(`${value}%`); // Format like '2023-01%' to match by month or '2023-01-01' for exact date + paramCounter++; + } + }); + + // Handle select filters + if (req.query.vendor) { + conditions.push(`p.vendor = $${paramCounter}`); + params.push(req.query.vendor); + paramCounter++; + } + + if (req.query.brand) { + conditions.push(`p.brand = $${paramCounter}`); + params.push(req.query.brand); + paramCounter++; + } + + if (req.query.category) { + conditions.push(`p.categories ILIKE $${paramCounter}`); + params.push(`%${req.query.category}%`); + paramCounter++; + } + + if (req.query.stockStatus && req.query.stockStatus !== 'all') { + conditions.push(`pm.stock_status = $${paramCounter}`); + params.push(req.query.stockStatus); + paramCounter++; + } + + if (req.query.abcClass) { + conditions.push(`pm.abc_class = $${paramCounter}`); + params.push(req.query.abcClass); + paramCounter++; + } + + if (req.query.leadTimeStatus) { + conditions.push(`pm.lead_time_status = $${paramCounter}`); + params.push(req.query.leadTimeStatus); + paramCounter++; + } + + if (req.query.replenishable !== undefined) { + conditions.push(`p.replenishable = $${paramCounter}`); + params.push(req.query.replenishable === 'true'); + paramCounter++; + } + + if (req.query.managingStock !== undefined) { + conditions.push(`p.managing_stock = $${paramCounter}`); + params.push(req.query.managingStock === 'true'); + paramCounter++; + } + + // Combine all conditions with AND + const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; + + // Get total count for pagination + const countQuery = ` + SELECT COUNT(DISTINCT p.pid) as total + FROM products p + LEFT JOIN product_metrics pm ON p.pid = pm.pid + ${whereClause} + `; + const { rows: [countResult] } = await pool.query(countQuery, params); + const total = countResult.total; + + // Get available filters + const { rows: categories } = await pool.query( + 'SELECT name FROM categories ORDER BY name' + ); + const { rows: vendors } = await pool.query( + 'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != \'\' ORDER BY vendor' + ); + const { rows: brands } = await pool.query( + 'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand' + ); + + // Main query with all fields + const query = ` + WITH RECURSIVE + category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ), + product_thresholds AS ( + SELECT + p.pid, + COALESCE( + (SELECT overstock_days FROM stock_thresholds st + WHERE st.category_id IN ( + SELECT pc.cat_id + FROM product_categories pc + WHERE pc.pid = p.pid + ) + AND (st.vendor = p.vendor OR st.vendor IS NULL) + ORDER BY st.vendor IS NULL + LIMIT 1), + (SELECT overstock_days FROM stock_thresholds st + WHERE st.category_id IS NULL + AND (st.vendor = p.vendor OR st.vendor IS NULL) + ORDER BY st.vendor IS NULL + LIMIT 1), + 90 + ) as target_days + FROM products p + ), + product_leaf_categories AS ( + SELECT DISTINCT pc.cat_id + FROM product_categories pc + WHERE NOT EXISTS ( + SELECT 1 + FROM categories child + JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id + WHERE child.parent_id = pc.cat_id + AND child_pc.pid = pc.pid + ) + ) + SELECT + p.*, + COALESCE(p.brand, 'Unbranded') as brand, + string_agg(DISTINCT (c.cat_id || ':' || c.name), ',') as categories, + pm.daily_sales_avg, + pm.weekly_sales_avg, + pm.monthly_sales_avg, + pm.avg_quantity_per_order, + pm.number_of_orders, + pm.first_sale_date, + pm.last_sale_date, + pm.days_of_inventory, + pm.weeks_of_inventory, + pm.reorder_point, + pm.safety_stock, + pm.avg_margin_percent, + CAST(pm.total_revenue AS DECIMAL(15,3)) as total_revenue, + CAST(pm.inventory_value AS DECIMAL(15,3)) as inventory_value, + CAST(pm.cost_of_goods_sold AS DECIMAL(15,3)) as cost_of_goods_sold, + CAST(pm.gross_profit AS DECIMAL(15,3)) as gross_profit, + pm.gmroi, + pm.avg_lead_time_days, + pm.last_purchase_date, + pm.last_received_date, + pm.abc_class, + pm.stock_status, + pm.turnover_rate, + p.date_last_sold + FROM products p + LEFT JOIN product_metrics pm ON p.pid = pm.pid + LEFT JOIN product_categories pc ON p.pid = pc.pid + LEFT JOIN categories c ON pc.cat_id = c.cat_id + ${whereClause} + GROUP BY p.pid, pm.pid + ORDER BY ${sortColumn} ${sortDirection} + LIMIT $${paramCounter} OFFSET $${paramCounter + 1} + `; + + params.push(limit, offset); + const { rows: products } = await pool.query(query, params); + + res.json({ + products, + pagination: { + total, + pages: Math.ceil(total / limit), + currentPage: page, + limit + }, + filters: { + categories: categories.map(c => c.name), + vendors: vendors.map(v => v.vendor), + brands: brands.map(b => b.brand) + } + }); + } catch (error) { + console.error('Error fetching products:', error); + res.status(500).json({ error: 'Failed to fetch products' }); + } +}); + +// Get trending products +router.get('/trending', async (req, res) => { + const pool = req.app.locals.pool; + try { + // First check if we have any data + const { rows } = await pool.query(` + SELECT COUNT(*) as count, + MAX(total_revenue) as max_revenue, + MAX(daily_sales_avg) as max_daily_sales, + COUNT(DISTINCT pid) as products_with_metrics + FROM product_metrics + WHERE total_revenue > 0 OR daily_sales_avg > 0 + `); + console.log('Product metrics stats:', rows[0]); + + if (parseInt(rows[0].count) === 0) { + console.log('No products with metrics found'); + return res.json([]); + } + + // Get trending products + const { rows: trendingProducts } = await pool.query(` + SELECT + p.pid, + p.sku, + p.title, + COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg, + COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg, + CASE + WHEN pm.weekly_sales_avg > 0 AND pm.daily_sales_avg > 0 + THEN ((pm.daily_sales_avg - pm.weekly_sales_avg) / pm.weekly_sales_avg) * 100 + ELSE 0 + END as growth_rate, + COALESCE(pm.total_revenue, 0) as total_revenue + FROM products p + INNER JOIN product_metrics pm ON p.pid = pm.pid + WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0) + AND p.visible = true + ORDER BY growth_rate DESC + LIMIT 50 + `); + + console.log('Trending products:', trendingProducts); + res.json(trendingProducts); + } catch (error) { + console.error('Error fetching trending products:', error); + res.status(500).json({ error: 'Failed to fetch trending products' }); + } +}); + +// Get a single product +router.get('/:id', async (req, res) => { + try { + const pool = req.app.locals.pool; + const id = parseInt(req.params.id); + + // Common CTE for category paths + const categoryPathCTE = ` + WITH RECURSIVE category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ) + `; + + // Get product details with category paths + const { rows: productRows } = await pool.query(` + SELECT + p.*, + pm.daily_sales_avg, + pm.weekly_sales_avg, + pm.monthly_sales_avg, + pm.days_of_inventory, + pm.reorder_point, + pm.safety_stock, + pm.stock_status, + pm.abc_class, + pm.avg_margin_percent, + pm.total_revenue, + pm.inventory_value, + pm.turnover_rate, + pm.gmroi, + pm.cost_of_goods_sold, + pm.gross_profit, + pm.avg_lead_time_days, + pm.current_lead_time, + pm.target_lead_time, + pm.lead_time_status, + pm.reorder_qty, + pm.overstocked_amt + FROM products p + LEFT JOIN product_metrics pm ON p.pid = pm.pid + WHERE p.pid = $1 + `, [id]); + + if (!productRows.length) { + return res.status(404).json({ error: 'Product not found' }); + } + + // Get categories and their paths separately to avoid GROUP BY issues + const { rows: categoryRows } = await pool.query(` + WITH RECURSIVE + category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ), + product_leaf_categories AS ( + -- Find categories assigned to this product that aren't parents + -- of other categories assigned to this product + SELECT pc.cat_id + FROM product_categories pc + WHERE pc.pid = $1 + AND NOT EXISTS ( + -- Check if there are any child categories also assigned to this product + SELECT 1 + FROM categories child + JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id + WHERE child.parent_id = pc.cat_id + AND child_pc.pid = pc.pid + ) + ) + SELECT + c.cat_id, + c.name as category_name, + cp.path as full_path + FROM product_categories pc + JOIN categories c ON pc.cat_id = c.cat_id + JOIN category_path cp ON c.cat_id = cp.cat_id + JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id + WHERE pc.pid = $2 + ORDER BY cp.path + `, [id, id]); + + // Transform the results + const categoryPathMap = categoryRows.reduce((acc, row) => { + // Use cat_id in the key to differentiate categories with the same name + acc[`${row.cat_id}:${row.category_name}`] = row.full_path; + return acc; + }, {}); + + const product = { + ...productRows[0], + // Include cat_id in categories array to match the keys in categoryPathMap + categories: categoryRows.map(row => `${row.cat_id}:${row.category_name}`), + category_paths: categoryPathMap, + price: parseFloat(productRows[0].price), + regular_price: parseFloat(productRows[0].regular_price), + cost_price: parseFloat(productRows[0].cost_price), + landing_cost_price: parseFloat(productRows[0].landing_cost_price), + stock_quantity: parseInt(productRows[0].stock_quantity), + moq: parseInt(productRows[0].moq), + uom: parseInt(productRows[0].uom), + managing_stock: Boolean(productRows[0].managing_stock), + replenishable: Boolean(productRows[0].replenishable), + // Format new fields + preorder_count: parseInt(productRows[0].preorder_count || 0), + notions_inv_count: parseInt(productRows[0].notions_inv_count || 0), + harmonized_tariff_code: productRows[0].harmonized_tariff_code || '', + notions_reference: productRows[0].notions_reference || '', + line: productRows[0].line || '', + subline: productRows[0].subline || '', + artist: productRows[0].artist || '', + rating: parseFloat(productRows[0].rating || 0), + reviews: parseInt(productRows[0].reviews || 0), + weight: parseFloat(productRows[0].weight || 0), + dimensions: { + length: parseFloat(productRows[0].length || 0), + width: parseFloat(productRows[0].width || 0), + height: parseFloat(productRows[0].height || 0), + }, + country_of_origin: productRows[0].country_of_origin || '', + location: productRows[0].location || '', + total_sold: parseInt(productRows[0].total_sold || 0), + baskets: parseInt(productRows[0].baskets || 0), + notifies: parseInt(productRows[0].notifies || 0), + date_last_sold: productRows[0].date_last_sold || null, + // Format existing analytics fields + daily_sales_avg: parseFloat(productRows[0].daily_sales_avg) || 0, + weekly_sales_avg: parseFloat(productRows[0].weekly_sales_avg) || 0, + monthly_sales_avg: parseFloat(productRows[0].monthly_sales_avg) || 0, + avg_quantity_per_order: parseFloat(productRows[0].avg_quantity_per_order) || 0, + number_of_orders: parseInt(productRows[0].number_of_orders) || 0, + first_sale_date: productRows[0].first_sale_date || null, + last_sale_date: productRows[0].last_sale_date || null, + days_of_inventory: parseFloat(productRows[0].days_of_inventory) || 0, + weeks_of_inventory: parseFloat(productRows[0].weeks_of_inventory) || 0, + reorder_point: parseFloat(productRows[0].reorder_point) || 0, + safety_stock: parseFloat(productRows[0].safety_stock) || 0, + avg_margin_percent: parseFloat(productRows[0].avg_margin_percent) || 0, + total_revenue: parseFloat(productRows[0].total_revenue) || 0, + inventory_value: parseFloat(productRows[0].inventory_value) || 0, + cost_of_goods_sold: parseFloat(productRows[0].cost_of_goods_sold) || 0, + gross_profit: parseFloat(productRows[0].gross_profit) || 0, + gmroi: parseFloat(productRows[0].gmroi) || 0, + avg_lead_time_days: parseFloat(productRows[0].avg_lead_time_days) || 0, + current_lead_time: parseFloat(productRows[0].current_lead_time) || 0, + target_lead_time: parseFloat(productRows[0].target_lead_time) || 0, + lead_time_status: productRows[0].lead_time_status || null, + reorder_qty: parseInt(productRows[0].reorder_qty) || 0, + overstocked_amt: parseInt(productRows[0].overstocked_amt) || 0 + }; + + res.json(product); + } catch (error) { + console.error('Error fetching product:', error); + res.status(500).json({ error: 'Failed to fetch product' }); + } +}); + +// Get product time series data +router.get('/:id/time-series', async (req, res) => { + const { id } = req.params; + try { + const pool = req.app.locals.pool; + + // Get monthly sales data + const { rows: monthlySales } = await pool.query(` + SELECT + TO_CHAR(date, 'YYYY-MM') as month, + COUNT(DISTINCT order_number) as order_count, + SUM(quantity) as units_sold, + ROUND(SUM(price * quantity)::numeric, 3) as revenue + FROM orders + WHERE pid = $1 + AND canceled = false + GROUP BY TO_CHAR(date, 'YYYY-MM') + ORDER BY month DESC + LIMIT 12 + `, [id]); + + // Format monthly sales data + const formattedMonthlySales = monthlySales.map(month => ({ + month: month.month, + order_count: parseInt(month.order_count), + units_sold: parseInt(month.units_sold), + revenue: parseFloat(month.revenue), + profit: 0 // Set to 0 since we don't have cost data in orders table + })); + + // Get recent orders + const { rows: recentOrders } = await pool.query(` + SELECT + TO_CHAR(date, 'YYYY-MM-DD') as date, + order_number, + quantity, + price, + discount, + tax, + shipping, + customer_name as customer, + status + FROM orders + WHERE pid = $1 + AND canceled = false + ORDER BY date DESC + LIMIT 10 + `, [id]); + + // Get recent purchase orders with detailed status + const { rows: recentPurchases } = await pool.query(` + SELECT + TO_CHAR(date, 'YYYY-MM-DD') as date, + TO_CHAR(expected_date, 'YYYY-MM-DD') as expected_date, + TO_CHAR(received_date, 'YYYY-MM-DD') as received_date, + po_id, + ordered, + received, + status, + receiving_status, + cost_price, + notes, + CASE + WHEN received_date IS NOT NULL THEN + (received_date - date) + WHEN expected_date < CURRENT_DATE AND status < $2 THEN + (CURRENT_DATE - expected_date) + ELSE NULL + END as lead_time_days + FROM purchase_orders + WHERE pid = $1 + AND status != $3 + ORDER BY date DESC + LIMIT 10 + `, [id, PurchaseOrderStatus.ReceivingStarted, PurchaseOrderStatus.Canceled]); + + res.json({ + monthly_sales: formattedMonthlySales, + recent_orders: recentOrders.map(order => ({ + ...order, + price: parseFloat(order.price), + discount: parseFloat(order.discount), + tax: parseFloat(order.tax), + shipping: parseFloat(order.shipping), + quantity: parseInt(order.quantity) + })), + recent_purchases: recentPurchases.map(po => ({ + ...po, + ordered: parseInt(po.ordered), + received: parseInt(po.received), + status: parseInt(po.status), + receiving_status: parseInt(po.receiving_status), + cost_price: parseFloat(po.cost_price), + lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null + })) + }); + } catch (error) { + console.error('Error fetching product time series:', error); + res.status(500).json({ error: 'Failed to fetch product time series' }); + } +}); + +module.exports = router; diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js new file mode 100644 index 0000000..0c272c8 --- /dev/null +++ b/inventory-server/src/routes/purchase-orders.js @@ -0,0 +1,1188 @@ +const express = require('express'); +const router = express.Router(); + +// Status code constants +// Frontend uses these numeric codes but database uses strings +const STATUS = { + CANCELED: 0, + CREATED: 1, + ELECTRONICALLY_READY_SEND: 10, + ORDERED: 11, + PREORDERED: 12, + ELECTRONICALLY_SENT: 13, + RECEIVING_STARTED: 15, + DONE: 50, + // Receiving status codes + RECEIVING_CREATED: 1, + RECEIVING_PARTIAL: 30, + RECEIVING_FULL: 40, + RECEIVING_PAID: 50 +}; + +// Status mapping from database string values to frontend numeric codes +const STATUS_MAPPING = { + 'canceled': STATUS.CANCELED, + 'created': STATUS.CREATED, + 'electronically_ready_send': STATUS.ELECTRONICALLY_READY_SEND, + 'ordered': STATUS.ORDERED, + 'preordered': STATUS.PREORDERED, + 'electronically_sent': STATUS.ELECTRONICALLY_SENT, + 'receiving_started': STATUS.RECEIVING_STARTED, + 'done': STATUS.DONE, + // Receiving status mappings + 'partial_received': STATUS.RECEIVING_PARTIAL, + 'full_received': STATUS.RECEIVING_FULL, + 'paid': STATUS.RECEIVING_PAID +}; + +// Helper for SQL status value comparison with string values in DB +function getStatusWhereClause(statusNum) { + const dbStatuses = Object.keys(STATUS_MAPPING).filter(key => + STATUS_MAPPING[key] === parseInt(statusNum)); + + if (dbStatuses.length > 0) { + return `po.status = '${dbStatuses[0]}'`; + } + return `1=0`; // No match found, return false condition +} + +// Get all purchase orders with summary metrics +router.get('/', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Parse query parameters with defaults + const { + search = '', + status = 'all', + vendor = 'all', + recordType = 'all', + startDate = null, + endDate = null, + page = 1, + limit = 100, + sortColumn = 'id', + sortDirection = 'desc' + } = req.query; + + console.log("Received query parameters:", { + search, status, vendor, recordType, page, limit, sortColumn, sortDirection + }); + + // Base where clause for purchase orders + let poWhereClause = '1=1'; + // Base where clause for receivings (used in the receiving_data CTE) + let receivingWhereClause = '1=1'; + const params = []; + let paramCounter = 1; + + if (search && search.trim() !== '') { + // Simplified search for purchase orders - improved performance + const searchTerm = `%${search.trim()}%`; + poWhereClause += ` AND ( + po.po_id::text ILIKE $${paramCounter} + OR po.vendor ILIKE $${paramCounter} + OR po.notes ILIKE $${paramCounter} + )`; + params.push(searchTerm); + paramCounter++; + + // Add search for receivings + receivingWhereClause += ` AND ( + r.receiving_id::text ILIKE $${paramCounter} + OR r.vendor ILIKE $${paramCounter} + )`; + params.push(searchTerm); + paramCounter++; + } + + if (status && status !== 'all') { + poWhereClause += ` AND ${getStatusWhereClause(status)}`; + + // Handle status for receivings + const dbStatuses = Object.keys(STATUS_MAPPING).filter(key => + STATUS_MAPPING[key] === parseInt(status)); + + if (dbStatuses.length > 0) { + receivingWhereClause += ` AND r.status = '${dbStatuses[0]}'`; + } + } + + if (vendor && vendor !== 'all') { + poWhereClause += ` AND po.vendor = $${paramCounter}`; + params.push(vendor); + paramCounter++; + + // Add vendor filter for receivings + receivingWhereClause += ` AND r.vendor = $${paramCounter}`; + params.push(vendor); + paramCounter++; + } + + if (startDate) { + poWhereClause += ` AND po.date >= $${paramCounter}::date`; + params.push(startDate); + paramCounter++; + + // Add date filter for receivings + receivingWhereClause += ` AND r.received_date >= $${paramCounter}::date`; + params.push(startDate); + paramCounter++; + } + + if (endDate) { + poWhereClause += ` AND po.date <= $${paramCounter}::date`; + params.push(endDate); + paramCounter++; + + // Add date filter for receivings + receivingWhereClause += ` AND r.received_date <= $${paramCounter}::date`; + params.push(endDate); + paramCounter++; + } + + // Get filtered summary metrics + const summaryQuery = ` + WITH po_totals AS ( + SELECT + po_id, + SUM(ordered) as total_ordered, + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost + FROM purchase_orders po + WHERE ${poWhereClause} + GROUP BY po_id + ), + receiving_totals AS ( + SELECT + r.receiving_id as po_id, + SUM(r.qty_each) as total_received + FROM receivings r + WHERE (${receivingWhereClause}) + AND r.receiving_id IN (SELECT po_id FROM po_totals) + GROUP BY r.receiving_id + ) + SELECT + COUNT(DISTINCT po.po_id) as order_count, + SUM(po.total_ordered) as total_ordered, + COALESCE(SUM(r.total_received), 0) as total_received, + CASE + WHEN SUM(po.total_ordered) > 0 + THEN ROUND((COALESCE(SUM(r.total_received), 0)::numeric / SUM(po.total_ordered)), 3) + ELSE 0 + END as fulfillment_rate, + ROUND(SUM(po.total_cost)::numeric, 3) as total_value, + CASE + WHEN COUNT(DISTINCT po.po_id) > 0 + THEN ROUND(AVG(po.total_cost)::numeric, 3) + ELSE 0 + END as avg_cost + FROM po_totals po + LEFT JOIN receiving_totals r ON po.po_id = r.po_id + `; + + const { rows: [summary] } = await pool.query(summaryQuery, params); + + // Prepare query based on record type filter to get correct counts + let countQuery = ''; + + if (recordType === 'po_only') { + countQuery = ` + WITH po_data AS ( + SELECT po_id + FROM purchase_orders po + WHERE ${poWhereClause} + GROUP BY po_id + ), + receiving_data AS ( + SELECT receiving_id + FROM receivings r + WHERE ${receivingWhereClause} + GROUP BY receiving_id + ), + filtered_data AS ( + SELECT DISTINCT po_id as id + FROM po_data + WHERE po_id NOT IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL) + ) + SELECT COUNT(*) as total FROM filtered_data + `; + } else if (recordType === 'po_with_receiving') { + countQuery = ` + WITH po_data AS ( + SELECT po_id + FROM purchase_orders po + WHERE ${poWhereClause} + GROUP BY po_id + ), + receiving_data AS ( + SELECT receiving_id + FROM receivings r + WHERE ${receivingWhereClause} + GROUP BY receiving_id + ), + filtered_data AS ( + SELECT DISTINCT po_id as id + FROM po_data + WHERE po_id IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL) + ) + SELECT COUNT(*) as total FROM filtered_data + `; + } else if (recordType === 'receiving_only') { + countQuery = ` + WITH po_data AS ( + SELECT po_id + FROM purchase_orders po + WHERE ${poWhereClause} + GROUP BY po_id + ), + receiving_data AS ( + SELECT receiving_id + FROM receivings r + WHERE ${receivingWhereClause} + GROUP BY receiving_id + ), + filtered_data AS ( + SELECT DISTINCT receiving_id as id + FROM receiving_data + WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL) + ) + SELECT COUNT(*) as total FROM filtered_data + `; + } else { + // 'all' - count both purchase orders and receiving-only records + countQuery = ` + WITH po_data AS ( + SELECT po_id + FROM purchase_orders po + WHERE ${poWhereClause} + GROUP BY po_id + ), + receiving_data AS ( + SELECT receiving_id + FROM receivings r + WHERE ${receivingWhereClause} + GROUP BY receiving_id + ), + filtered_data AS ( + SELECT DISTINCT po_id as id FROM po_data + UNION + SELECT DISTINCT receiving_id as id FROM receiving_data + WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL) + ) + SELECT COUNT(*) as total FROM filtered_data + `; + } + + const { rows: [countResult] } = await pool.query(countQuery, params); + + // Parse parameters safely + const parsedPage = parseInt(page) || 1; + const parsedLimit = parseInt(limit) || 100; + const total = parseInt(countResult?.total) || 0; + const offset = (parsedPage - 1) * parsedLimit; + const pages = Math.ceil(total / parsedLimit); + + // Validated sort parameters + const validSortColumns = ['id', 'vendor_name', 'order_date', 'receiving_date', + 'status', 'total_cost', 'total_items', 'total_quantity', 'total_received', 'fulfillment_rate']; + + const finalSortColumn = validSortColumns.includes(sortColumn) ? sortColumn : 'id'; + const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; + + // Build the order by clause with improved null handling + let orderByClause = ''; + + // Special sorting that ensures receiving_only records are included with any date sorting + if (finalSortColumn === 'order_date' || finalSortColumn === 'date') { + orderByClause = ` + CASE + WHEN order_date IS NULL THEN + CASE WHEN receiving_date IS NOT NULL THEN + to_date(receiving_date, 'YYYY-MM-DD') + ELSE + '1900-01-01'::date + END + ELSE + to_date(order_date, 'YYYY-MM-DD') + END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'} + `; + } else if (finalSortColumn === 'receiving_date') { + orderByClause = ` + CASE WHEN receiving_date IS NULL THEN + '1900-01-01'::date + ELSE + to_date(receiving_date, 'YYYY-MM-DD') + END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'} + `; + } else if (finalSortColumn === 'vendor_name') { + orderByClause = `vendor_name ${finalSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; + } else if (finalSortColumn === 'total_cost' || finalSortColumn === 'total_received' || + finalSortColumn === 'total_items' || finalSortColumn === 'total_quantity' || finalSortColumn === 'fulfillment_rate') { + orderByClause = `COALESCE(${finalSortColumn}, 0) ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}`; + } else if (finalSortColumn === 'status') { + // For status sorting, first convert to numeric values for consistent sorting + orderByClause = ` + CASE + WHEN status = 'canceled' THEN 0 + WHEN status = 'created' THEN 1 + WHEN status = 'electronically_ready_send' THEN 10 + WHEN status = 'ordered' THEN 11 + WHEN status = 'receiving_started' THEN 15 + WHEN status = 'done' THEN 50 + WHEN status = 'partial_received' THEN 30 + WHEN status = 'full_received' THEN 40 + WHEN status = 'paid' THEN 50 + ELSE 999 + END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'} + `; + } else { + // Default to ID sorting + orderByClause = `id::text::bigint ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}`; + } + + // Main query to get purchase orders and receivings + let orderQuery = ` + WITH po_data AS ( + SELECT + po_id, + vendor, + date, + status, + COUNT(DISTINCT pid) as total_items, + SUM(ordered) as total_quantity, + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost, + MAX(notes) as short_note + FROM purchase_orders po + WHERE ${poWhereClause} + GROUP BY po_id, vendor, date, status + ), + receiving_data AS ( + SELECT + r.receiving_id, + MAX(r.received_date) as receiving_date, + r.vendor as receiving_vendor, + COUNT(DISTINCT r.pid) as total_items, + SUM(r.qty_each) as total_received, + ROUND(SUM(r.qty_each * r.cost_each)::numeric, 3) as total_cost, + MAX(r.status) as receiving_status + FROM receivings r + WHERE ${receivingWhereClause} + GROUP BY r.receiving_id, r.vendor + )`; + + // Add appropriate record type filtering based on the filter value + if (recordType === 'po_only') { + orderQuery += `, + all_data AS ( + SELECT DISTINCT po_id as id + FROM po_data + WHERE po_id NOT IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL) + )`; + } else if (recordType === 'po_with_receiving') { + orderQuery += `, + all_data AS ( + SELECT DISTINCT po_id as id + FROM po_data + WHERE po_id IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL) + )`; + } else if (recordType === 'receiving_only') { + orderQuery += `, + all_data AS ( + SELECT DISTINCT receiving_id as id + FROM receiving_data + WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL) + )`; + } else { + // 'all' - include all records + orderQuery += `, + all_data AS ( + SELECT DISTINCT po_id as id FROM po_data + UNION + SELECT DISTINCT receiving_id as id FROM receiving_data + WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL) + )`; + } + + // Complete the query with combined data and ordering + orderQuery += ` + ,combined_data AS ( + SELECT + a.id, + COALESCE(po.vendor, r.receiving_vendor) as vendor_name, + to_char(po.date, 'YYYY-MM-DD') as order_date, + to_char(r.receiving_date, 'YYYY-MM-DD') as receiving_date, + CASE + WHEN po.po_id IS NULL THEN r.receiving_status + ELSE po.status + END as status, + COALESCE(po.total_items, r.total_items, 0) as total_items, + COALESCE(po.total_quantity, 0) as total_quantity, + COALESCE(po.total_cost, r.total_cost, 0) as total_cost, + COALESCE(r.total_received, 0) as total_received, + CASE + WHEN po.po_id IS NULL THEN 1 + WHEN r.receiving_id IS NULL THEN 0 + WHEN po.total_quantity = 0 THEN 0 + ELSE ROUND((r.total_received::numeric / po.total_quantity), 3) + END as fulfillment_rate, + po.short_note, + CASE + WHEN po.po_id IS NULL THEN 'receiving_only' + WHEN r.receiving_id IS NULL THEN 'po_only' + ELSE 'po_with_receiving' + END as record_type + FROM all_data a + LEFT JOIN po_data po ON a.id = po.po_id + LEFT JOIN receiving_data r ON a.id = r.receiving_id + ) + SELECT * FROM combined_data + ORDER BY ${orderByClause}, id::text::bigint DESC + LIMIT $${paramCounter} OFFSET $${paramCounter + 1} + `; + + const { rows: orders } = await pool.query(orderQuery, [...params, parsedLimit, offset]); + + // Get unique vendors for filter options + const { rows: vendors } = await pool.query(` + SELECT DISTINCT vendor + FROM purchase_orders + WHERE vendor IS NOT NULL AND vendor != '' + UNION + SELECT DISTINCT vendor + FROM receivings + WHERE vendor IS NOT NULL AND vendor != '' + ORDER BY vendor + `); + + // Get unique statuses for filter options + const { rows: statuses } = await pool.query(` + SELECT DISTINCT status + FROM purchase_orders + WHERE status IS NOT NULL + UNION + SELECT DISTINCT status + FROM receivings + WHERE status IS NOT NULL + ORDER BY status + `); + + // Get product vendors for orders with Unknown Vendor + const poIds = orders.filter(o => o.vendor_name === 'Unknown Vendor').map(o => o.id); + let vendorMappings = {}; + + if (poIds.length > 0) { + const { rows: productVendors } = await pool.query(` + SELECT DISTINCT po.po_id, p.vendor + FROM purchase_orders po + JOIN products p ON po.pid = p.pid + WHERE po.po_id = ANY($1) + AND p.vendor IS NOT NULL AND p.vendor != '' + GROUP BY po.po_id, p.vendor + `, [poIds]); + + // Create mapping of PO ID to actual vendor from products table + vendorMappings = productVendors.reduce((acc, pv) => { + if (!acc[pv.po_id]) { + acc[pv.po_id] = pv.vendor; + } + return acc; + }, {}); + } + + // Parse numeric values and map status strings to numeric codes + const parsedOrders = orders.map(order => { + // Special handling for status mapping + let statusCode; + if (order.record_type === 'receiving_only') { + // For receiving-only records, use receiving status codes + statusCode = STATUS_MAPPING[order.status] || 0; + } else { + // For PO records, use PO status codes + statusCode = STATUS_MAPPING[order.status] || 0; + } + + return { + id: order.id, + vendor_name: vendorMappings[order.id] || order.vendor_name, + order_date: order.order_date, + receiving_date: order.receiving_date, + status: statusCode, + total_items: Number(order.total_items) || 0, + total_quantity: Number(order.total_quantity) || 0, + total_cost: Number(order.total_cost) || 0, + total_received: Number(order.total_received) || 0, + fulfillment_rate: Number(order.fulfillment_rate) || 0, + short_note: order.short_note, + record_type: order.record_type + }; + }); + + // Parse summary metrics with fallbacks + const parsedSummary = { + order_count: Number(summary?.order_count) || 0, + total_ordered: Number(summary?.total_ordered) || 0, + total_received: Number(summary?.total_received) || 0, + fulfillment_rate: Number(summary?.fulfillment_rate) || 0, + total_value: Number(summary?.total_value) || 0, + avg_cost: Number(summary?.avg_cost) || 0 + }; + + console.log(`Returning ${parsedOrders.length} orders, total=${total}, pages=${pages}, page=${parsedPage}`); + + res.json({ + orders: parsedOrders, + summary: parsedSummary, + pagination: { + total, + pages, + page: parsedPage, + limit: parsedLimit + }, + filters: { + vendors: vendors.map(v => v.vendor), + statuses: statuses.map(s => STATUS_MAPPING[s.status] || 0) // Map string statuses to numeric codes for the frontend + } + }); + } catch (error) { + console.error('Error fetching purchase orders:', error); + res.status(500).json({ error: 'Failed to fetch purchase orders', details: error.message }); + } +}); + +// Get vendor performance metrics +router.get('/vendor-metrics', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows: metrics } = await pool.query(` + WITH po_data AS ( + SELECT + vendor, + po_id, + SUM(ordered) as total_ordered, + AVG(po_cost_price) as avg_cost_price, + MAX(date) as po_date + FROM purchase_orders + WHERE vendor IS NOT NULL AND vendor != '' + AND status != 'canceled' -- Exclude canceled orders + GROUP BY vendor, po_id + ), + receiving_data AS ( + SELECT + r.receiving_id as po_id, + SUM(r.qty_each) as total_received, + MIN(r.received_date) as first_received_date + FROM receivings r + JOIN purchase_orders po ON r.receiving_id = po.po_id + WHERE po.vendor IS NOT NULL AND po.vendor != '' + AND po.status != 'canceled' + GROUP BY r.receiving_id + ), + delivery_metrics AS ( + SELECT + po.vendor, + po.po_id, + po.total_ordered as ordered, + COALESCE(r.total_received, 0) as received, + po.avg_cost_price as po_cost_price, + CASE + WHEN r.first_received_date IS NOT NULL AND po.po_date IS NOT NULL + THEN EXTRACT(DAY FROM (r.first_received_date - po.po_date)) + ELSE NULL + END as delivery_days + FROM po_data po + LEFT JOIN receiving_data r ON po.po_id = r.po_id + ) + SELECT + vendor as vendor_name, + COUNT(DISTINCT po_id) as total_orders, + SUM(ordered) as total_ordered, + SUM(received) as total_received, + ROUND( + (SUM(received)::numeric / NULLIF(SUM(ordered), 0)), 3 + ) as fulfillment_rate, + ROUND( + (SUM(ordered * po_cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2 + ) as avg_unit_cost, + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_spend, + ROUND( + AVG(NULLIF(delivery_days, 0))::numeric, 1 + ) as avg_delivery_days + FROM delivery_metrics + GROUP BY vendor + HAVING COUNT(DISTINCT po_id) > 0 + ORDER BY total_spend DESC + `); + + // Parse numeric values + const parsedMetrics = metrics.map(vendor => ({ + id: vendor.vendor_name, + vendor_name: vendor.vendor_name, + total_orders: Number(vendor.total_orders) || 0, + total_ordered: Number(vendor.total_ordered) || 0, + total_received: Number(vendor.total_received) || 0, + fulfillment_rate: Number(vendor.fulfillment_rate) || 0, + avg_unit_cost: Number(vendor.avg_unit_cost) || 0, + total_spend: Number(vendor.total_spend) || 0, + avg_delivery_days: Number(vendor.avg_delivery_days) || 0 + })); + + res.json(parsedMetrics); + } catch (error) { + console.error('Error fetching vendor metrics:', error); + res.status(500).json({ error: 'Failed to fetch vendor metrics' }); + } +}); + +// Get cost analysis +router.get('/cost-analysis', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows: analysis } = await pool.query(` + WITH category_costs AS ( + SELECT + c.name as category, + po.pid, + po.po_cost_price as cost_price, + po.ordered, + po.status + FROM purchase_orders po + JOIN product_categories pc ON po.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + WHERE po.status != 'canceled' -- Exclude canceled orders + ) + SELECT + category, + COUNT(DISTINCT pid) as unique_products, + ROUND(AVG(cost_price)::numeric, 3) as avg_cost, + ROUND(MIN(cost_price)::numeric, 3) as min_cost, + ROUND(MAX(cost_price)::numeric, 3) as max_cost, + ROUND(STDDEV(cost_price)::numeric, 3) as cost_variance, + ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend + FROM category_costs + GROUP BY category + ORDER BY total_spend DESC + `); + + // Parse numeric values and include ALL data for each category + const parsedAnalysis = { + unique_products: 0, + avg_cost: 0, + min_cost: 0, + max_cost: 0, + cost_variance: 0, + total_spend_by_category: analysis.map(cat => ({ + category: cat.category, + unique_products: Number(cat.unique_products) || 0, + avg_cost: Number(cat.avg_cost) || 0, + min_cost: Number(cat.min_cost) || 0, + max_cost: Number(cat.max_cost) || 0, + cost_variance: Number(cat.cost_variance) || 0, + total_spend: Number(cat.total_spend) || 0 + })) + }; + + // Calculate aggregated stats if data exists + if (analysis.length > 0) { + parsedAnalysis.unique_products = analysis.reduce((sum, cat) => sum + Number(cat.unique_products || 0), 0); + + // Calculate weighted average cost + const totalProducts = parsedAnalysis.unique_products; + if (totalProducts > 0) { + parsedAnalysis.avg_cost = analysis.reduce((sum, cat) => + sum + (Number(cat.avg_cost || 0) * Number(cat.unique_products || 0)), 0) / totalProducts; + } + + // Find min and max across all categories + parsedAnalysis.min_cost = Math.min(...analysis.map(cat => Number(cat.min_cost || 0))); + parsedAnalysis.max_cost = Math.max(...analysis.map(cat => Number(cat.max_cost || 0))); + + // Average variance + parsedAnalysis.cost_variance = analysis.reduce((sum, cat) => + sum + Number(cat.cost_variance || 0), 0) / analysis.length; + } + + res.json(parsedAnalysis); + } catch (error) { + console.error('Error fetching cost analysis:', error); + res.status(500).json({ error: 'Failed to fetch cost analysis' }); + } +}); + +// New endpoint for yearly category spending analysis based on receivings +router.get('/category-analysis', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Allow an optional "since" parameter or default to 1 year ago + const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const { rows: analysis } = await pool.query(` + WITH receiving_costs AS ( + SELECT + c.name as category, + r.pid, + r.cost_each as received_cost, + r.qty_each as received_qty, + r.received_date + FROM receivings r + JOIN product_categories pc ON r.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + WHERE r.received_date >= $1::date + AND r.qty_each > 0 -- Only consider actual received quantities + ) + SELECT + category, + COUNT(DISTINCT pid) as unique_products, + ROUND(AVG(received_cost)::numeric, 3) as avg_cost, + ROUND(MIN(received_cost)::numeric, 3) as min_cost, + ROUND(MAX(received_cost)::numeric, 3) as max_cost, + ROUND(STDDEV(received_cost)::numeric, 3) as cost_variance, + ROUND(SUM(received_qty * received_cost)::numeric, 3) as total_spend + FROM receiving_costs + GROUP BY category + ORDER BY total_spend DESC + `, [since]); + + // Parse numeric values + const parsedAnalysis = analysis.map(cat => ({ + category: cat.category, + unique_products: Number(cat.unique_products) || 0, + avg_cost: Number(cat.avg_cost) || 0, + min_cost: Number(cat.min_cost) || 0, + max_cost: Number(cat.max_cost) || 0, + cost_variance: Number(cat.cost_variance) || 0, + total_spend: Number(cat.total_spend) || 0 + })); + + res.json(parsedAnalysis); + } catch (error) { + console.error('Error fetching category analysis:', error); + res.status(500).json({ error: 'Failed to fetch category analysis' }); + } +}); + +// New endpoint for yearly vendor spending analysis based on receivings +router.get('/vendor-analysis', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Allow an optional "since" parameter or default to 1 year ago + const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const { rows: metrics } = await pool.query(` + WITH receiving_data AS ( + SELECT + r.vendor, + r.receiving_id, + r.pid, + r.qty_each, + r.cost_each, + r.received_date + FROM receivings r + WHERE r.received_date >= $1::date + AND r.qty_each > 0 -- Only consider actual received quantities + ), + receiving_totals AS ( + SELECT + vendor, + receiving_id, + COUNT(DISTINCT pid) as unique_products, + SUM(qty_each) as total_received, + SUM(qty_each * cost_each) as total_spend + FROM receiving_data + GROUP BY vendor, receiving_id + ) + SELECT + vendor, + COUNT(DISTINCT receiving_id) as orders, + ROUND(SUM(total_spend)::numeric, 3) as total_spend, + SUM(total_received) as total_received, + SUM(unique_products) as total_items + FROM receiving_totals + WHERE vendor IS NOT NULL AND vendor != '' + GROUP BY vendor + HAVING COUNT(DISTINCT receiving_id) > 0 + ORDER BY total_spend DESC + `, [since]); + + // Parse numeric values + const parsedMetrics = metrics.map(vendor => ({ + vendor: vendor.vendor, + orders: Number(vendor.orders) || 0, + total_spend: Number(vendor.total_spend) || 0, + total_received: Number(vendor.total_received) || 0, + total_items: Number(vendor.total_items) || 0 + })); + + res.json(parsedMetrics); + } catch (error) { + console.error('Error fetching vendor analysis:', error); + res.status(500).json({ error: 'Failed to fetch vendor analysis' }); + } +}); + +// Get order status metrics +router.get('/receiving-status', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows: status } = await pool.query(` + WITH po_totals AS ( + SELECT + po_id, + status, + SUM(ordered) as total_ordered, + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost + FROM purchase_orders + WHERE status != 'canceled' + GROUP BY po_id, status + ), + receiving_totals AS ( + SELECT + po.po_id, + SUM(r.qty_each) as total_received + FROM receivings r + JOIN purchase_orders po ON r.pid = po.pid AND r.sku = po.sku + WHERE po.po_id IN (SELECT po_id FROM po_totals) + GROUP BY po.po_id + ), + combined_data AS ( + SELECT + po.po_id, + po.status, + po.total_ordered, + po.total_cost, + COALESCE(r.total_received, 0) as total_received + FROM po_totals po + LEFT JOIN receiving_totals r ON po.po_id = r.po_id + ) + SELECT + COUNT(DISTINCT po_id) as order_count, + SUM(total_ordered) as total_ordered, + SUM(total_received) as total_received, + ROUND( + SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3 + ) as fulfillment_rate, + ROUND(SUM(total_cost)::numeric, 3) as total_value, + ROUND(AVG(total_cost)::numeric, 3) as avg_cost, + COUNT(DISTINCT CASE + WHEN status = 'created' THEN po_id + END) as pending_count, + COUNT(DISTINCT CASE + WHEN status = 'receiving_started' THEN po_id + END) as partial_count, + COUNT(DISTINCT CASE + WHEN status = 'done' THEN po_id + END) as completed_count, + COUNT(DISTINCT CASE + WHEN status = 'canceled' THEN po_id + END) as canceled_count + FROM combined_data + `); + + // Parse numeric values + const parsedStatus = { + order_count: Number(status[0]?.order_count) || 0, + total_ordered: Number(status[0]?.total_ordered) || 0, + total_received: Number(status[0]?.total_received) || 0, + fulfillment_rate: Number(status[0]?.fulfillment_rate) || 0, + total_value: Number(status[0]?.total_value) || 0, + avg_cost: Number(status[0]?.avg_cost) || 0, + status_breakdown: { + pending: Number(status[0]?.pending_count) || 0, + partial: Number(status[0]?.partial_count) || 0, + completed: Number(status[0]?.completed_count) || 0, + canceled: Number(status[0]?.canceled_count) || 0 + } + }; + + res.json(parsedStatus); + } catch (error) { + console.error('Error fetching receiving status:', error); + res.status(500).json({ error: 'Failed to fetch receiving status' }); + } +}); + +// Get order vs received quantities by product +router.get('/order-vs-received', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows: quantities } = await pool.query(` + WITH order_data AS ( + SELECT + p.pid, + p.title, + p.SKU, + SUM(po.ordered) as ordered_quantity, + COUNT(DISTINCT po.po_id) as order_count + FROM products p + JOIN purchase_orders po ON p.pid = po.pid + WHERE po.date >= (CURRENT_DATE - INTERVAL '90 days') + GROUP BY p.pid, p.title, p.SKU + ), + receiving_data AS ( + SELECT + r.pid, + SUM(r.qty_each) as received_quantity + FROM receivings r + JOIN purchase_orders po ON r.receiving_id = po.po_id + WHERE r.received_date >= (CURRENT_DATE - INTERVAL '90 days') + GROUP BY r.pid + ) + SELECT + o.pid as product_id, + o.title as product, + o.SKU as sku, + o.ordered_quantity, + COALESCE(r.received_quantity, 0) as received_quantity, + ROUND( + COALESCE(r.received_quantity, 0) / NULLIF(o.ordered_quantity, 0) * 100, 1 + ) as fulfillment_rate, + o.order_count + FROM order_data o + LEFT JOIN receiving_data r ON o.pid = r.pid + WHERE o.order_count > 0 + ORDER BY o.ordered_quantity DESC + LIMIT 20 + `); + + // Parse numeric values and add id for React keys + const parsedQuantities = quantities.map(q => ({ + id: q.product_id, + ...q, + ordered_quantity: Number(q.ordered_quantity) || 0, + received_quantity: Number(q.received_quantity) || 0, + fulfillment_rate: Number(q.fulfillment_rate) || 0, + order_count: Number(q.order_count) || 0 + })); + + res.json(parsedQuantities); + } catch (error) { + console.error('Error fetching order vs received quantities:', error); + res.status(500).json({ error: 'Failed to fetch order vs received quantities' }); + } +}); + +// Get purchase order items +router.get('/:id/items', async (req, res) => { + try { + const pool = req.app.locals.pool; + const { id } = req.params; + + if (!id) { + return res.status(400).json({ error: 'Purchase order ID is required' }); + } + + // Query to get purchase order items with product details + const { rows: items } = await pool.query(` + WITH po_items AS ( + SELECT + po.po_id, + po.pid, + po.sku, + COALESCE(po.name, p.title) as product_name, + po.po_cost_price, + po.ordered, + po.status + FROM purchase_orders po + LEFT JOIN products p ON po.pid = p.pid + WHERE po.po_id = $1 + ), + receiving_items AS ( + SELECT + r.receiving_id, + r.pid, + r.sku, + SUM(r.qty_each) as received + FROM receivings r + WHERE r.receiving_id = $1 + GROUP BY r.receiving_id, r.pid, r.sku + ) + SELECT + pi.po_id as id, + pi.pid, + pi.sku, + pi.product_name, + p.barcode, + pi.po_cost_price, + pi.ordered, + COALESCE(ri.received, 0) as received, + ROUND(pi.ordered * pi.po_cost_price, 2) as total_cost, + CASE + WHEN ri.received IS NULL THEN 'Not Received' + WHEN ri.received = 0 THEN 'Not Received' + WHEN ri.received < pi.ordered THEN 'Partially Received' + WHEN ri.received >= pi.ordered THEN 'Fully Received' + END as receiving_status + FROM po_items pi + LEFT JOIN receiving_items ri ON pi.pid = ri.pid AND pi.sku = ri.sku + LEFT JOIN products p ON pi.pid = p.pid + ORDER BY pi.product_name + `, [id]); + + // Parse numeric values + const parsedItems = items.map(item => ({ + id: `${item.id}_${item.pid}`, + pid: item.pid, + product_name: item.product_name, + sku: item.sku, + upc: item.barcode || 'N/A', + ordered: Number(item.ordered) || 0, + received: Number(item.received) || 0, + po_cost_price: Number(item.po_cost_price) || 0, + total_cost: Number(item.total_cost) || 0, + receiving_status: item.receiving_status + })); + + res.json(parsedItems); + } catch (error) { + console.error('Error fetching purchase order items:', error); + res.status(500).json({ error: 'Failed to fetch purchase order items', details: error.message }); + } +}); + +// Get receiving items +router.get('/receiving/:id/items', async (req, res) => { + try { + const pool = req.app.locals.pool; + const { id } = req.params; + + if (!id) { + return res.status(400).json({ error: 'Receiving ID is required' }); + } + + // Query to get receiving items with related PO information if available + const { rows: items } = await pool.query(` + WITH receiving_items AS ( + SELECT + r.receiving_id, + r.pid, + r.sku, + COALESCE(r.name, p.title) as product_name, + r.cost_each, + r.qty_each, + r.status + FROM receivings r + LEFT JOIN products p ON r.pid = p.pid + WHERE r.receiving_id = $1 + ), + po_items AS ( + SELECT + po.po_id, + po.pid, + po.sku, + po.ordered, + po.po_cost_price + FROM purchase_orders po + WHERE po.po_id = $1 + ) + SELECT + ri.receiving_id as id, + ri.pid, + ri.sku, + ri.product_name, + p.barcode, + COALESCE(po.ordered, 0) as ordered, + ri.qty_each as received, + COALESCE(po.po_cost_price, ri.cost_each) as po_cost_price, + ri.cost_each, + ROUND(ri.qty_each * ri.cost_each, 2) as total_cost, + CASE + WHEN po.ordered IS NULL THEN 'Receiving Only' + WHEN ri.qty_each < po.ordered THEN 'Partially Received' + WHEN ri.qty_each >= po.ordered THEN 'Fully Received' + END as receiving_status + FROM receiving_items ri + LEFT JOIN po_items po ON ri.pid = po.pid AND ri.sku = po.sku + LEFT JOIN products p ON ri.pid = p.pid + ORDER BY ri.product_name + `, [id]); + + // Parse numeric values + const parsedItems = items.map(item => ({ + id: `${item.id}_${item.pid}`, + pid: item.pid, + product_name: item.product_name, + sku: item.sku, + upc: item.barcode || 'N/A', + ordered: Number(item.ordered) || 0, + received: Number(item.received) || 0, + po_cost_price: Number(item.po_cost_price) || 0, + cost_each: Number(item.cost_each) || 0, + qty_each: Number(item.received) || 0, + total_cost: Number(item.total_cost) || 0, + receiving_status: item.receiving_status + })); + + res.json(parsedItems); + } catch (error) { + console.error('Error fetching receiving items:', error); + res.status(500).json({ error: 'Failed to fetch receiving items', details: error.message }); + } +}); + +// New endpoint for delivery metrics +router.get('/delivery-metrics', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows: deliveryData } = await pool.query(` + WITH po_dates AS ( + SELECT + po_id, + date as order_date + FROM purchase_orders + WHERE status != 'canceled' + GROUP BY po_id, date + ), + receiving_dates AS ( + SELECT + receiving_id as po_id, + MIN(received_date) as first_received_date + FROM receivings + GROUP BY receiving_id + ), + delivery_times AS ( + SELECT + po.po_id, + po.order_date, + r.first_received_date, + CASE + WHEN r.first_received_date IS NOT NULL AND po.order_date IS NOT NULL + THEN (r.first_received_date::date - po.order_date::date) + ELSE NULL + END as delivery_days + FROM po_dates po + JOIN receiving_dates r ON po.po_id = r.po_id + WHERE + r.first_received_date IS NOT NULL + AND po.order_date IS NOT NULL + AND r.first_received_date::date >= po.order_date::date + ) + SELECT + ROUND(AVG(delivery_days)::numeric, 1) as avg_delivery_days, + ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY delivery_days)::numeric, 1) as median_delivery_days, + MIN(delivery_days) as min_delivery_days, + MAX(delivery_days) as max_delivery_days, + COUNT(*) as total_orders_with_delivery + FROM delivery_times + WHERE delivery_days >= 0 AND delivery_days <= 365 -- Filter out unreasonable values + `); + + res.json({ + avg_delivery_days: Number(deliveryData[0]?.avg_delivery_days) || 0, + median_delivery_days: Number(deliveryData[0]?.median_delivery_days) || 0, + min_delivery_days: Number(deliveryData[0]?.min_delivery_days) || 0, + max_delivery_days: Number(deliveryData[0]?.max_delivery_days) || 0, + total_orders_with_delivery: Number(deliveryData[0]?.total_orders_with_delivery) || 0 + }); + } catch (error) { + console.error('Error fetching delivery metrics:', error); + res.status(500).json({ error: 'Failed to fetch delivery metrics' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/reusable-images.js b/inventory-server/src/routes/reusable-images.js new file mode 100644 index 0000000..7dd3f96 --- /dev/null +++ b/inventory-server/src/routes/reusable-images.js @@ -0,0 +1,396 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); + +// Create reusable uploads directory if it doesn't exist +const uploadsDir = path.join('/var/www/html/inventory/uploads/reusable'); +fs.mkdirSync(uploadsDir, { recursive: true }); + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + console.log(`Saving reusable image to: ${uploadsDir}`); + cb(null, uploadsDir); + }, + filename: function (req, file, cb) { + // Create unique filename with original extension + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + + // Make sure we preserve the original file extension + let fileExt = path.extname(file.originalname).toLowerCase(); + + // Ensure there is a proper extension based on mimetype if none exists + if (!fileExt) { + switch (file.mimetype) { + case 'image/jpeg': fileExt = '.jpg'; break; + case 'image/png': fileExt = '.png'; break; + case 'image/gif': fileExt = '.gif'; break; + case 'image/webp': fileExt = '.webp'; break; + default: fileExt = '.jpg'; // Default to jpg + } + } + + const fileName = `reusable-${uniqueSuffix}${fileExt}`; + console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`); + cb(null, fileName); + } +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB max file size + }, + fileFilter: function (req, file, cb) { + // Accept only image files + const filetypes = /jpeg|jpg|png|gif|webp/; + const mimetype = filetypes.test(file.mimetype); + const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); + + if (mimetype && extname) { + return cb(null, true); + } + cb(new Error('Only image files are allowed')); + } +}); + +// Get all reusable images +router.get('/', async (req, res) => { + try { + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM reusable_images + ORDER BY created_at DESC + `); + res.json(result.rows); + } catch (error) { + console.error('Error fetching reusable images:', error); + res.status(500).json({ + error: 'Failed to fetch reusable images', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get images by company or global images +router.get('/by-company/:companyId', async (req, res) => { + try { + const { companyId } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Get images that are either global or belong to this company + const result = await pool.query(` + SELECT * FROM reusable_images + WHERE is_global = true OR company = $1 + ORDER BY created_at DESC + `, [companyId]); + + res.json(result.rows); + } catch (error) { + console.error('Error fetching reusable images by company:', error); + res.status(500).json({ + error: 'Failed to fetch reusable images by company', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get global images only +router.get('/global', async (req, res) => { + try { + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM reusable_images + WHERE is_global = true + ORDER BY created_at DESC + `); + + res.json(result.rows); + } catch (error) { + console.error('Error fetching global reusable images:', error); + res.status(500).json({ + error: 'Failed to fetch global reusable images', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get a single image by ID +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM reusable_images + WHERE id = $1 + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Reusable image not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching reusable image:', error); + res.status(500).json({ + error: 'Failed to fetch reusable image', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Upload a new reusable image +router.post('/upload', upload.single('image'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No image file provided' }); + } + + const { name, is_global, company } = req.body; + + // Validate required fields + if (!name) { + return res.status(400).json({ error: 'Image name is required' }); + } + + // Convert is_global from string to boolean + const isGlobal = is_global === 'true' || is_global === true; + + // Validate company is provided for non-global images + if (!isGlobal && !company) { + return res.status(400).json({ error: 'Company is required for non-global images' }); + } + + // Log file information + console.log('Reusable image uploaded:', { + filename: req.file.filename, + originalname: req.file.originalname, + mimetype: req.file.mimetype, + size: req.file.size, + path: req.file.path + }); + + // Ensure the file exists + const filePath = path.join(uploadsDir, req.file.filename); + if (!fs.existsSync(filePath)) { + return res.status(500).json({ error: 'File was not saved correctly' }); + } + + // Create URL for the uploaded file + const baseUrl = 'https://acot.site'; + const imageUrl = `${baseUrl}/uploads/reusable/${req.file.filename}`; + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Insert record into database + const result = await pool.query(` + INSERT INTO reusable_images ( + name, + filename, + file_path, + image_url, + is_global, + company, + mime_type, + file_size + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `, [ + name, + req.file.filename, + filePath, + imageUrl, + isGlobal, + isGlobal ? null : company, + req.file.mimetype, + req.file.size + ]); + + // Return success response with image data + res.status(201).json({ + success: true, + image: result.rows[0], + message: 'Image uploaded successfully' + }); + + } catch (error) { + console.error('Error uploading reusable image:', error); + res.status(500).json({ error: error.message || 'Failed to upload image' }); + } +}); + +// Update image details (name, is_global, company) +router.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const { name, is_global, company } = req.body; + + // Validate required fields + if (!name) { + return res.status(400).json({ error: 'Image name is required' }); + } + + // Convert is_global from string to boolean if necessary + const isGlobal = typeof is_global === 'string' ? is_global === 'true' : !!is_global; + + // Validate company is provided for non-global images + if (!isGlobal && !company) { + return res.status(400).json({ error: 'Company is required for non-global images' }); + } + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Check if the image exists + const checkResult = await pool.query('SELECT * FROM reusable_images WHERE id = $1', [id]); + if (checkResult.rows.length === 0) { + return res.status(404).json({ error: 'Reusable image not found' }); + } + + const result = await pool.query(` + UPDATE reusable_images + SET + name = $1, + is_global = $2, + company = $3 + WHERE id = $4 + RETURNING * + `, [ + name, + isGlobal, + isGlobal ? null : company, + id + ]); + + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating reusable image:', error); + res.status(500).json({ + error: 'Failed to update reusable image', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Delete a reusable image +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Get the image data first to get the filename + const imageResult = await pool.query('SELECT * FROM reusable_images WHERE id = $1', [id]); + + if (imageResult.rows.length === 0) { + return res.status(404).json({ error: 'Reusable image not found' }); + } + + const image = imageResult.rows[0]; + + // Delete from database + await pool.query('DELETE FROM reusable_images WHERE id = $1', [id]); + + // Delete the file from filesystem + const filePath = path.join(uploadsDir, image.filename); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + res.json({ + message: 'Reusable image deleted successfully', + image + }); + } catch (error) { + console.error('Error deleting reusable image:', error); + res.status(500).json({ + error: 'Failed to delete reusable image', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Check if file exists and permissions +router.get('/check-file/:filename', (req, res) => { + const { filename } = req.params; + + // Prevent directory traversal + if (filename.includes('..') || filename.includes('/')) { + return res.status(400).json({ error: 'Invalid filename' }); + } + + const filePath = path.join(uploadsDir, filename); + + try { + // Check if file exists + if (!fs.existsSync(filePath)) { + return res.status(404).json({ + error: 'File not found', + path: filePath, + exists: false, + readable: false + }); + } + + // Check if file is readable + fs.accessSync(filePath, fs.constants.R_OK); + + // Get file stats + const stats = fs.statSync(filePath); + + return res.json({ + filename, + path: filePath, + exists: true, + readable: true, + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + size: stats.size, + created: stats.birthtime, + modified: stats.mtime, + permissions: stats.mode.toString(8) + }); + } catch (error) { + return res.status(500).json({ + error: error.message, + path: filePath, + exists: fs.existsSync(filePath), + readable: false + }); + } +}); + +// Error handling middleware +router.use((err, req, res, next) => { + console.error('Reusable images route error:', err); + res.status(500).json({ + error: 'Internal server error', + details: err.message + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/templates.js b/inventory-server/src/routes/templates.js new file mode 100644 index 0000000..999f960 --- /dev/null +++ b/inventory-server/src/routes/templates.js @@ -0,0 +1,283 @@ +const express = require('express'); +const { getPool } = require('../utils/db'); +const dotenv = require('dotenv'); +const path = require('path'); + +dotenv.config({ path: path.join(__dirname, "../../.env") }); + +const router = express.Router(); + +// Get all templates +router.get('/', async (req, res) => { + try { + const pool = getPool(); + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM templates + ORDER BY company ASC, product_type ASC + `); + res.json(result.rows); + } catch (error) { + console.error('Error fetching templates:', error); + res.status(500).json({ + error: 'Failed to fetch templates', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get template by company and product type +router.get('/:company/:productType', async (req, res) => { + try { + const { company, productType } = req.params; + const pool = getPool(); + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM templates + WHERE company = $1 AND product_type = $2 + `, [company, productType]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Template not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching template:', error); + res.status(500).json({ + error: 'Failed to fetch template', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Create new template +router.post('/', async (req, res) => { + try { + const { + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions + } = req.body; + + // Validate required fields + if (!company || !product_type) { + return res.status(400).json({ error: 'Company and Product Type are required' }); + } + + const pool = getPool(); + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + INSERT INTO templates ( + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING * + `, [ + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions + ]); + + res.status(201).json(result.rows[0]); + } catch (error) { + console.error('Error creating template:', error); + // Check for unique constraint violation + if (error instanceof Error && error.message.includes('unique constraint')) { + return res.status(409).json({ + error: 'Template already exists for this company and product type', + details: error.message + }); + } + res.status(500).json({ + error: 'Failed to create template', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Update template +router.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const { + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions + } = req.body; + + // Validate required fields + if (!company || !product_type) { + return res.status(400).json({ error: 'Company and Product Type are required' }); + } + + const pool = getPool(); + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + UPDATE templates + SET + company = $1, + product_type = $2, + supplier = $3, + msrp = $4, + cost_each = $5, + qty_per_unit = $6, + case_qty = $7, + hts_code = $8, + description = $9, + weight = $10, + length = $11, + width = $12, + height = $13, + tax_cat = $14, + size_cat = $15, + categories = $16, + ship_restrictions = $17 + WHERE id = $18 + RETURNING * + `, [ + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions, + id + ]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Template not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating template:', error); + // Check for unique constraint violation + if (error instanceof Error && error.message.includes('unique constraint')) { + return res.status(409).json({ + error: 'Template already exists for this company and product type', + details: error.message + }); + } + res.status(500).json({ + error: 'Failed to update template', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Delete template +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = getPool(); + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query('DELETE FROM templates WHERE id = $1 RETURNING *', [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Template not found' }); + } + + res.json({ message: 'Template deleted successfully' }); + } catch (error) { + console.error('Error deleting template:', error); + res.status(500).json({ + error: 'Failed to delete template', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Error handling middleware +router.use((err, req, res, next) => { + console.error('Template route error:', err); + res.status(500).json({ + error: 'Internal server error', + details: err.message + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/vendorsAggregate.js b/inventory-server/src/routes/vendorsAggregate.js index 0519ecb..84e0544 100644 --- a/inventory-server/src/routes/vendorsAggregate.js +++ b/inventory-server/src/routes/vendorsAggregate.js @@ -1 +1,323 @@ - \ No newline at end of file +const express = require('express'); +const router = express.Router(); +const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed + +// --- Configuration & Helpers --- +const DEFAULT_PAGE_LIMIT = 50; +const MAX_PAGE_LIMIT = 200; + +// Maps query keys to DB columns in vendor_metrics +const COLUMN_MAP = { + vendorName: { dbCol: 'vm.vendor_name', type: 'string' }, + productCount: { dbCol: 'vm.product_count', type: 'number' }, + activeProductCount: { dbCol: 'vm.active_product_count', type: 'number' }, + replenishableProductCount: { dbCol: 'vm.replenishable_product_count', type: 'number' }, + currentStockUnits: { dbCol: 'vm.current_stock_units', type: 'number' }, + currentStockCost: { dbCol: 'vm.current_stock_cost', type: 'number' }, + currentStockRetail: { dbCol: 'vm.current_stock_retail', type: 'number' }, + onOrderUnits: { dbCol: 'vm.on_order_units', type: 'number' }, + onOrderCost: { dbCol: 'vm.on_order_cost', type: 'number' }, + poCount365d: { dbCol: 'vm.po_count_365d', type: 'number' }, + avgLeadTimeDays: { dbCol: 'vm.avg_lead_time_days', type: 'number' }, + sales7d: { dbCol: 'vm.sales_7d', type: 'number' }, + revenue7d: { dbCol: 'vm.revenue_7d', type: 'number' }, + sales30d: { dbCol: 'vm.sales_30d', type: 'number' }, + revenue30d: { dbCol: 'vm.revenue_30d', type: 'number' }, + profit30d: { dbCol: 'vm.profit_30d', type: 'number' }, + cogs30d: { dbCol: 'vm.cogs_30d', type: 'number' }, + sales365d: { dbCol: 'vm.sales_365d', type: 'number' }, + revenue365d: { dbCol: 'vm.revenue_365d', type: 'number' }, + lifetimeSales: { dbCol: 'vm.lifetime_sales', type: 'number' }, + lifetimeRevenue: { dbCol: 'vm.lifetime_revenue', type: 'number' }, + avgMargin30d: { dbCol: 'vm.avg_margin_30d', type: 'number' }, + // Growth metrics + salesGrowth30dVsPrev: { dbCol: 'vm.sales_growth_30d_vs_prev', type: 'number' }, + revenueGrowth30dVsPrev: { dbCol: 'vm.revenue_growth_30d_vs_prev', type: 'number' }, + // Add aliases if needed for frontend compatibility + name: { dbCol: 'vm.vendor_name', type: 'string' }, + leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' }, + // Add status for filtering + status: { dbCol: 'vendor_status', type: 'string' }, +}; + +function getSafeColumnInfo(queryParamKey) { + return COLUMN_MAP[queryParamKey] || null; +} + +// --- Route Handlers --- + +// GET /vendors-aggregate/filter-options (Just vendors list for now) +router.get('/filter-options', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /vendors-aggregate/filter-options'); + try { + // Get vendor names + const { rows: vendorRows } = await pool.query(` + SELECT DISTINCT vendor_name FROM public.vendor_metrics ORDER BY vendor_name + `); + + // Get status values - calculate them since they're derived + const { rows: statusRows } = await pool.query(` + SELECT DISTINCT + CASE + WHEN po_count_365d > 0 AND sales_30d > 0 THEN 'active' + WHEN po_count_365d > 0 THEN 'inactive' + ELSE 'pending' + END as status + FROM public.vendor_metrics + ORDER BY status + `); + + res.json({ + vendors: vendorRows.map(r => r.vendor_name), + statuses: statusRows.map(r => r.status) + }); + } catch(error) { + console.error('Error fetching vendor filter options:', error); + res.status(500).json({ error: 'Failed to fetch filter options' }); + } +}); + +// GET /vendors-aggregate/stats (Overall vendor stats) +router.get('/stats', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /vendors-aggregate/stats'); + try { + // Get basic vendor stats from aggregate table + const { rows: [stats] } = await pool.query(` + SELECT + COUNT(*) AS total_vendors, + SUM(active_product_count) AS total_active_products, + SUM(current_stock_cost) AS total_stock_value, + SUM(on_order_cost) AS total_on_order_value, + AVG(NULLIF(avg_lead_time_days, 0)) AS overall_avg_lead_time + FROM public.vendor_metrics vm + `); + + // Count active vendors based on criteria (from old vendors.js) + const { rows: [activeStats] } = await pool.query(` + SELECT + COUNT(DISTINCT CASE + WHEN po_count_365d > 0 + THEN vendor_name + END) as active_vendors + FROM public.vendor_metrics + `); + + // Get overall cost metrics from purchase orders + const { rows: [overallCostMetrics] } = await pool.query(` + SELECT + ROUND((SUM(ordered * po_cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost, + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_spend + FROM purchase_orders + WHERE po_cost_price IS NOT NULL + AND ordered > 0 + AND vendor IS NOT NULL AND vendor != '' + `); + + res.json({ + totalVendors: parseInt(stats?.total_vendors || 0), + activeVendors: parseInt(activeStats?.active_vendors || 0), + totalActiveProducts: parseInt(stats?.total_active_products || 0), + totalValue: parseFloat(stats?.total_stock_value || 0), + totalOnOrderValue: parseFloat(stats?.total_on_order_value || 0), + avgLeadTime: parseFloat(stats?.overall_avg_lead_time || 0), + avgUnitCost: parseFloat(overallCostMetrics?.avg_unit_cost || 0), + totalSpend: parseFloat(overallCostMetrics?.total_spend || 0) + }); + } catch (error) { + console.error('Error fetching vendor stats:', error); + res.status(500).json({ error: 'Failed to fetch vendor stats.' }); + } +}); + +// GET /vendors-aggregate/ (List vendors) +router.get('/', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /vendors-aggregate received query:', req.query); + try { + // --- Pagination --- + let page = parseInt(req.query.page, 10) || 1; + let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT; + limit = Math.min(limit, MAX_PAGE_LIMIT); + const offset = (page - 1) * limit; + + // --- Sorting --- + const sortQueryKey = req.query.sort || 'vendorName'; // Default sort + const sortColumnInfo = getSafeColumnInfo(sortQueryKey); + const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'vm.vendor_name'; + const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST'); + const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`; + + // --- Filtering --- + const conditions = []; + const params = []; + let paramCounter = 1; + // Build conditions based on req.query, using COLUMN_MAP and parseValue + for (const key in req.query) { + if (['page', 'limit', 'sort', 'order'].includes(key)) continue; + + let filterKey = key; + let operator = '='; // Default operator + const value = req.query[key]; + + const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/); + if (operatorMatch) { + filterKey = operatorMatch[1]; + operator = operatorMatch[2]; + } + + const columnInfo = getSafeColumnInfo(filterKey); + if (columnInfo) { + const dbColumn = columnInfo.dbCol; + const valueType = columnInfo.type; + try { + let conditionFragment = ''; + let needsParam = true; + switch (operator.toLowerCase()) { // Normalize operator + case 'eq': operator = '='; break; + case 'ne': operator = '<>'; break; + case 'gt': operator = '>'; break; + case 'gte': operator = '>='; break; + case 'lt': operator = '<'; break; + case 'lte': operator = '<='; break; + case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break; + case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break; + case 'between': + const [val1, val2] = String(value).split(','); + if (val1 !== undefined && val2 !== undefined) { + conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`; + params.push(parseValue(val1, valueType), parseValue(val2, valueType)); + needsParam = false; + } else continue; + break; + case 'in': + const inValues = String(value).split(','); + if (inValues.length > 0) { + const placeholders = inValues.map(() => `$${paramCounter++}`).join(', '); + conditionFragment = `${dbColumn} IN (${placeholders})`; + params.push(...inValues.map(v => parseValue(v, valueType))); + needsParam = false; + } else continue; + break; + default: operator = '='; break; + } + + if (needsParam) { + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; + params.push(parseValue(value, valueType)); + } else if (!conditionFragment) { // For LIKE/ILIKE + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; + } + + if (conditionFragment) { + conditions.push(`(${conditionFragment})`); + } + } catch (parseError) { + console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`); + if (needsParam) paramCounter--; + } + } else { + console.warn(`Invalid filter key ignored: ${key}`); + } + } + + // --- Execute Queries --- + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Status calculation from vendors.js + const statusCase = ` + CASE + WHEN po_count_365d > 0 AND sales_30d > 0 THEN 'active' + WHEN po_count_365d > 0 THEN 'inactive' + ELSE 'pending' + END as vendor_status + `; + + const baseSql = ` + FROM ( + SELECT + vm.*, + ${statusCase} + FROM public.vendor_metrics vm + ) vm + ${whereClause} + `; + + const countSql = `SELECT COUNT(*) AS total ${baseSql}`; + const dataSql = ` + WITH vendor_data AS ( + SELECT + vm.*, + ${statusCase} + FROM public.vendor_metrics vm + ) + SELECT + vm.*, + COALESCE(po.avg_unit_cost, 0) as avg_unit_cost, + COALESCE(po.total_spend, 0) as total_spend + FROM vendor_data vm + LEFT JOIN ( + SELECT + vendor, + ROUND((SUM(ordered * po_cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost, + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_spend + FROM purchase_orders + WHERE po_cost_price IS NOT NULL AND ordered > 0 + GROUP BY vendor + ) po ON vm.vendor_name = po.vendor + ${whereClause} + ${sortClause} + LIMIT $${paramCounter} OFFSET $${paramCounter + 1} + `; + const dataParams = [...params, limit, offset]; + + console.log("Count SQL:", countSql, params); + console.log("Data SQL:", dataSql, dataParams); + + const [countResult, dataResult] = await Promise.all([ + pool.query(countSql, params), + pool.query(dataSql, dataParams) + ]); + + const total = parseInt(countResult.rows[0].total, 10); + const vendors = dataResult.rows.map(row => { + // Create a new object with both snake_case and camelCase keys + const transformedRow = { ...row }; // Start with original data + + for (const key in row) { + // Skip null/undefined values + if (row[key] === null || row[key] === undefined) { + continue; // Original already has the null value + } + + // Transform keys to match frontend expectations (add camelCase versions) + // First handle cases like sales_7d -> sales7d + let camelKey = key.replace(/_(\d+[a-z])/g, '$1'); + + // Then handle regular snake_case -> camelCase + camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + if (camelKey !== key) { // Only add if different from original + transformedRow[camelKey] = row[key]; + } + } + return transformedRow; + }); + + // --- Respond --- + res.json({ + vendors, + pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit }, + }); + + } catch (error) { + console.error('Error fetching vendor metrics list:', error); + res.status(500).json({ error: 'Failed to fetch vendor metrics.' }); + } +}); + +// GET /vendors-aggregate/:name (Get single vendor metric) +// Implement if needed, remember to URL-decode the name parameter + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js new file mode 100644 index 0000000..c4a88b4 --- /dev/null +++ b/inventory-server/src/server.js @@ -0,0 +1,225 @@ +const express = require('express'); +const cors = require('cors'); +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { corsMiddleware, corsErrorHandler } = require('./middleware/cors'); +const { initPool } = require('./utils/db'); +const productsRouter = require('./routes/products'); +const dashboardRouter = require('./routes/dashboard'); +const ordersRouter = require('./routes/orders'); +const csvRouter = require('./routes/data-management'); +const analyticsRouter = require('./routes/analytics'); +const purchaseOrdersRouter = require('./routes/purchase-orders'); +const configRouter = require('./routes/config'); +const metricsRouter = require('./routes/metrics'); +const importRouter = require('./routes/import'); +const aiValidationRouter = require('./routes/ai-validation'); +const templatesRouter = require('./routes/templates'); +const aiPromptsRouter = require('./routes/ai-prompts'); +const reusableImagesRouter = require('./routes/reusable-images'); +const categoriesAggregateRouter = require('./routes/categoriesAggregate'); +const vendorsAggregateRouter = require('./routes/vendorsAggregate'); +const brandsAggregateRouter = require('./routes/brandsAggregate'); + +// Get the absolute path to the .env file +const envPath = '/var/www/html/inventory/.env'; +console.log('Looking for .env file at:', envPath); +console.log('.env file exists:', fs.existsSync(envPath)); + +try { + require('dotenv').config({ path: envPath }); + console.log('.env file loaded successfully'); + console.log('Environment check:', { + NODE_ENV: process.env.NODE_ENV || 'not set', + PORT: process.env.PORT || 'not set', + DB_HOST: process.env.DB_HOST || 'not set', + DB_USER: process.env.DB_USER || 'not set', + DB_NAME: process.env.DB_NAME || 'not set', + DB_PASSWORD: process.env.DB_PASSWORD ? '[password set]' : 'not set', + DB_PORT: process.env.DB_PORT || 'not set', + DB_SSL: process.env.DB_SSL || 'not set' + }); +} catch (error) { + console.error('Error loading .env file:', error); +} + +// Resolve important directories relative to the project root +const serverRoot = path.resolve(__dirname, '..'); +const configuredUploadsDir = process.env.UPLOADS_DIR; +const uploadsDir = configuredUploadsDir + ? (path.isAbsolute(configuredUploadsDir) + ? configuredUploadsDir + : path.resolve(serverRoot, configuredUploadsDir)) + : path.resolve(serverRoot, 'uploads'); + +// Persist the resolved uploads directory so downstream modules share the same path +process.env.UPLOADS_DIR = uploadsDir; + +const requiredDirs = [path.resolve(serverRoot, 'logs'), uploadsDir]; + +requiredDirs.forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}); + +const app = express(); + +// Debug middleware to log request details +app.use((req, res, next) => { + console.log('Request details:', { + method: req.method, + url: req.url, + origin: req.get('Origin'), + headers: req.headers + }); + next(); +}); + +// Apply CORS middleware first, before any other middleware +app.use(corsMiddleware); + +// Body parser middleware +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Initialize database pool and start server +async function startServer() { + try { + // Initialize database pool + const pool = await initPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432, + max: process.env.NODE_ENV === 'production' ? 20 : 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + ssl: process.env.DB_SSL === 'true' ? { + rejectUnauthorized: false + } : false + }); + + // Make pool available to routes + app.locals.pool = pool; + + // Set up routes after pool is initialized + app.use('/api/products', productsRouter); + app.use('/api/dashboard', dashboardRouter); + app.use('/api/orders', ordersRouter); + app.use('/api/csv', csvRouter); + app.use('/api/analytics', analyticsRouter); + app.use('/api/purchase-orders', purchaseOrdersRouter); + app.use('/api/config', configRouter); + app.use('/api/metrics', metricsRouter); + // Use only the aggregate routes for vendors and categories + app.use('/api/vendors', vendorsAggregateRouter); + app.use('/api/categories', categoriesAggregateRouter); + // Keep the aggregate-specific endpoints for backward compatibility + app.use('/api/categories-aggregate', categoriesAggregateRouter); + app.use('/api/vendors-aggregate', vendorsAggregateRouter); + app.use('/api/brands-aggregate', brandsAggregateRouter); + app.use('/api/import', importRouter); + app.use('/api/ai-validation', aiValidationRouter); + app.use('/api/templates', templatesRouter); + app.use('/api/ai-prompts', aiPromptsRouter); + app.use('/api/reusable-images', reusableImagesRouter); + + // Basic health check route + app.get('/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV + }); + }); + + // CORS error handler - must be before other error handlers + app.use(corsErrorHandler); + + // Error handling middleware - MUST be after routes and CORS error handler + app.use((err, req, res, next) => { + console.error(`[${new Date().toISOString()}] Error:`, err); + + // Send detailed error in development, generic in production + const error = process.env.NODE_ENV === 'production' + ? 'An internal server error occurred' + : err.message || err; + + res.status(err.status || 500).json({ error }); + }); + + const PORT = process.env.PORT || 3000; + app.listen(PORT, () => { + console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`); + }); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +// Handle uncaught exceptions +process.on('uncaughtException', (err) => { + console.error(`[${new Date().toISOString()}] Uncaught Exception:`, err); + process.exit(1); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason); +}); + +// Initialize client sets for SSE +const importClients = new Set(); +const updateClients = new Set(); +const resetClients = new Set(); +const resetMetricsClients = new Set(); + +// Helper function to send progress to SSE clients +const sendProgressToClients = (clients, data) => { + clients.forEach(client => { + try { + client.write(`data: ${JSON.stringify(data)}\n\n`); + } catch (error) { + console.error('Error sending SSE update:', error); + } + }); +}; + +// Setup SSE connection +const setupSSE = (req, res) => { + const { type } = req.params; + + // Set headers for SSE + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': req.headers.origin || '*', + 'Access-Control-Allow-Credentials': 'true' + }); + + // Send initial message + res.write('data: {"status":"connected"}\n\n'); + + // Add client to appropriate set + const clientSet = type === 'import' ? importClients : + type === 'update' ? updateClients : + type === 'reset' ? resetClients : + type === 'reset-metrics' ? resetMetricsClients : + null; + + if (clientSet) { + clientSet.add(res); + + // Remove client when connection closes + req.on('close', () => { + clientSet.delete(res); + }); + } +}; + +// Start the server +startServer(); diff --git a/inventory-server/src/types/status-codes.js b/inventory-server/src/types/status-codes.js new file mode 100644 index 0000000..8c94f85 --- /dev/null +++ b/inventory-server/src/types/status-codes.js @@ -0,0 +1,79 @@ +// Purchase Order Status Codes +const PurchaseOrderStatus = { + Canceled: 0, + Created: 1, + ElectronicallyReadySend: 10, + Ordered: 11, + Preordered: 12, + ElectronicallySent: 13, + ReceivingStarted: 15, + Done: 50 +}; + +// Receiving Status Codes +const ReceivingStatus = { + Canceled: 0, + Created: 1, + PartialReceived: 30, + FullReceived: 40, + Paid: 50 +}; + +// Status Code Display Names +const PurchaseOrderStatusLabels = { + [PurchaseOrderStatus.Canceled]: 'Canceled', + [PurchaseOrderStatus.Created]: 'Created', + [PurchaseOrderStatus.ElectronicallyReadySend]: 'Ready to Send', + [PurchaseOrderStatus.Ordered]: 'Ordered', + [PurchaseOrderStatus.Preordered]: 'Preordered', + [PurchaseOrderStatus.ElectronicallySent]: 'Sent', + [PurchaseOrderStatus.ReceivingStarted]: 'Receiving Started', + [PurchaseOrderStatus.Done]: 'Done' +}; + +const ReceivingStatusLabels = { + [ReceivingStatus.Canceled]: 'Canceled', + [ReceivingStatus.Created]: 'Created', + [ReceivingStatus.PartialReceived]: 'Partially Received', + [ReceivingStatus.FullReceived]: 'Fully Received', + [ReceivingStatus.Paid]: 'Paid' +}; + +// Helper functions +function getPurchaseOrderStatusLabel(status) { + return PurchaseOrderStatusLabels[status] || 'Unknown'; +} + +function getReceivingStatusLabel(status) { + return ReceivingStatusLabels[status] || 'Unknown'; +} + +// Status checks +function isReceivingComplete(status) { + return status >= ReceivingStatus.PartialReceived; +} + +function isPurchaseOrderComplete(status) { + return status === PurchaseOrderStatus.Done; +} + +function isPurchaseOrderCanceled(status) { + return status === PurchaseOrderStatus.Canceled; +} + +function isReceivingCanceled(status) { + return status === ReceivingStatus.Canceled; +} + +module.exports = { + PurchaseOrderStatus, + ReceivingStatus, + PurchaseOrderStatusLabels, + ReceivingStatusLabels, + getPurchaseOrderStatusLabel, + getReceivingStatusLabel, + isReceivingComplete, + isPurchaseOrderComplete, + isPurchaseOrderCanceled, + isReceivingCanceled +}; \ No newline at end of file diff --git a/inventory-server/src/utils/apiHelpers.js b/inventory-server/src/utils/apiHelpers.js new file mode 100644 index 0000000..559fa29 --- /dev/null +++ b/inventory-server/src/utils/apiHelpers.js @@ -0,0 +1,45 @@ +/** + * Parses a query parameter value based on its expected type. + * Throws error for invalid formats. Adjust date handling as needed. + */ +function parseValue(value, type) { + if (value === null || value === undefined || value === '') return null; + + console.log(`Parsing value: "${value}" as type: "${type}"`); + + switch (type) { + case 'number': + const num = parseFloat(value); + if (isNaN(num)) { + console.error(`Invalid number format: "${value}"`); + throw new Error(`Invalid number format: "${value}"`); + } + return num; + case 'integer': // Specific type for integer IDs etc. + const int = parseInt(value, 10); + if (isNaN(int)) { + console.error(`Invalid integer format: "${value}"`); + throw new Error(`Invalid integer format: "${value}"`); + } + console.log(`Successfully parsed integer: ${int}`); + return int; + case 'boolean': + if (String(value).toLowerCase() === 'true') return true; + if (String(value).toLowerCase() === 'false') return false; + console.error(`Invalid boolean format: "${value}"`); + throw new Error(`Invalid boolean format: "${value}"`); + case 'date': + // Basic ISO date format validation (YYYY-MM-DD) + if (!String(value).match(/^\d{4}-\d{2}-\d{2}$/)) { + console.warn(`Potentially invalid date format passed: "${value}"`); + // Optionally throw an error or return null depending on strictness + // throw new Error(`Invalid date format (YYYY-MM-DD expected): "${value}"`); + } + return String(value); // Send as string, let DB handle casting/comparison + case 'string': + default: + return String(value); + } +} + +module.exports = { parseValue }; \ No newline at end of file diff --git a/inventory-server/src/utils/csvImporter.js b/inventory-server/src/utils/csvImporter.js new file mode 100644 index 0000000..6f69c71 --- /dev/null +++ b/inventory-server/src/utils/csvImporter.js @@ -0,0 +1,63 @@ +const fs = require('fs'); +const { parse } = require('csv-parse'); +const { v4: uuidv4 } = require('uuid'); + +async function importProductsFromCSV(filePath, pool) { + return new Promise((resolve, reject) => { + const products = []; + + fs.createReadStream(filePath) + .pipe(parse({ + columns: true, + skip_empty_lines: true + })) + .on('data', async (row) => { + products.push({ + id: uuidv4(), + sku: row.sku, + name: row.name, + description: row.description || null, + category: row.category || null + }); + }) + .on('end', async () => { + try { + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + for (const product of products) { + await connection.query( + 'INSERT INTO products (id, sku, name, description, category) VALUES (?, ?, ?, ?, ?)', + [product.id, product.sku, product.name, product.description, product.category] + ); + + // Initialize inventory level for the product + await connection.query( + 'INSERT INTO inventory_levels (id, product_id, quantity) VALUES (?, ?, 0)', + [uuidv4(), product.id] + ); + } + + await connection.commit(); + resolve({ imported: products.length }); + } catch (error) { + await connection.rollback(); + reject(error); + } finally { + connection.release(); + } + } catch (error) { + reject(error); + } + }) + .on('error', (error) => { + reject(error); + }); + }); +} + +module.exports = { + importProductsFromCSV +}; \ No newline at end of file diff --git a/inventory-server/src/utils/db.js b/inventory-server/src/utils/db.js new file mode 100644 index 0000000..5ece8ba --- /dev/null +++ b/inventory-server/src/utils/db.js @@ -0,0 +1,21 @@ +const { Pool } = require('pg'); + +let pool; + +function initPool(config) { + pool = new Pool(config); + return pool; +} + +async function getConnection() { + if (!pool) { + throw new Error('Database pool not initialized'); + } + return pool.connect(); +} + +module.exports = { + initPool, + getConnection, + getPool: () => pool +}; \ No newline at end of file diff --git a/inventory-server/src/utils/dbConnection.js b/inventory-server/src/utils/dbConnection.js new file mode 100644 index 0000000..3763e1d --- /dev/null +++ b/inventory-server/src/utils/dbConnection.js @@ -0,0 +1,239 @@ +const { Client } = require('ssh2'); +const mysql = require('mysql2/promise'); +const fs = require('fs'); + +// Connection pooling and cache configuration +const connectionCache = { + ssh: null, + dbConnection: null, + lastUsed: 0, + isConnecting: false, + connectionPromise: null, + // Cache expiration time in milliseconds (5 minutes) + expirationTime: 5 * 60 * 1000, + // Cache for query results (key: query string, value: {data, timestamp}) + queryCache: new Map(), + // Cache duration for different query types in milliseconds + cacheDuration: { + 'field-options': 30 * 60 * 1000, // 30 minutes for field options + 'product-lines': 10 * 60 * 1000, // 10 minutes for product lines + 'sublines': 10 * 60 * 1000, // 10 minutes for sublines + 'taxonomy': 30 * 60 * 1000, // 30 minutes for taxonomy data + 'default': 60 * 1000 // 1 minute default + } +}; + +/** + * Get a database connection with connection pooling + * @returns {Promise<{ssh: object, connection: object}>} The SSH and database connection + */ +async function getDbConnection() { + const now = Date.now(); + + // Check if we need to refresh the connection due to inactivity + const needsRefresh = !connectionCache.ssh || + !connectionCache.dbConnection || + (now - connectionCache.lastUsed > connectionCache.expirationTime); + + // If connection is still valid, update last used time and return existing connection + if (!needsRefresh) { + connectionCache.lastUsed = now; + return { + ssh: connectionCache.ssh, + connection: connectionCache.dbConnection + }; + } + + // If another request is already establishing a connection, wait for that promise + if (connectionCache.isConnecting && connectionCache.connectionPromise) { + try { + await connectionCache.connectionPromise; + return { + ssh: connectionCache.ssh, + connection: connectionCache.dbConnection + }; + } catch (error) { + // If that connection attempt failed, we'll try again below + console.error('Error waiting for existing connection:', error); + } + } + + // Close existing connections if they exist + if (connectionCache.dbConnection) { + try { + await connectionCache.dbConnection.end(); + } catch (error) { + console.error('Error closing existing database connection:', error); + } + } + + if (connectionCache.ssh) { + try { + connectionCache.ssh.end(); + } catch (error) { + console.error('Error closing existing SSH connection:', error); + } + } + + // Mark that we're establishing a new connection + connectionCache.isConnecting = true; + + // Create a new promise for this connection attempt + connectionCache.connectionPromise = setupSshTunnel().then(tunnel => { + const { ssh, stream, dbConfig } = tunnel; + + return mysql.createConnection({ + ...dbConfig, + stream + }).then(connection => { + // Store the new connections + connectionCache.ssh = ssh; + connectionCache.dbConnection = connection; + connectionCache.lastUsed = Date.now(); + connectionCache.isConnecting = false; + + return { + ssh, + connection + }; + }); + }).catch(error => { + connectionCache.isConnecting = false; + throw error; + }); + + // Wait for the connection to be established + return connectionCache.connectionPromise; +} + +/** + * Get cached query results or execute query if not cached + * @param {string} cacheKey - Unique key to identify the query + * @param {string} queryType - Type of query (field-options, product-lines, etc.) + * @param {Function} queryFn - Function to execute if cache miss + * @returns {Promise} The query result + */ +async function getCachedQuery(cacheKey, queryType, queryFn) { + // Get cache duration based on query type + const cacheDuration = connectionCache.cacheDuration[queryType] || connectionCache.cacheDuration.default; + + // Check if we have a valid cached result + const cachedResult = connectionCache.queryCache.get(cacheKey); + const now = Date.now(); + + if (cachedResult && (now - cachedResult.timestamp < cacheDuration)) { + console.log(`Cache hit for ${queryType} query: ${cacheKey}`); + return cachedResult.data; + } + + // No valid cache found, execute the query + console.log(`Cache miss for ${queryType} query: ${cacheKey}`); + const result = await queryFn(); + + // Cache the result + connectionCache.queryCache.set(cacheKey, { + data: result, + timestamp: now + }); + + return result; +} + +/** + * Setup SSH tunnel to production database + * @private - Should only be used by getDbConnection + * @returns {Promise<{ssh: object, stream: object, dbConfig: object}>} + */ +async function setupSshTunnel() { + const sshConfig = { + host: process.env.PROD_SSH_HOST, + port: process.env.PROD_SSH_PORT || 22, + username: process.env.PROD_SSH_USER, + privateKey: process.env.PROD_SSH_KEY_PATH + ? fs.readFileSync(process.env.PROD_SSH_KEY_PATH) + : undefined, + compress: true + }; + + const dbConfig = { + host: process.env.PROD_DB_HOST || 'localhost', + user: process.env.PROD_DB_USER, + password: process.env.PROD_DB_PASSWORD, + database: process.env.PROD_DB_NAME, + port: process.env.PROD_DB_PORT || 3306, + timezone: 'Z' + }; + + return new Promise((resolve, reject) => { + const ssh = new Client(); + + ssh.on('error', (err) => { + console.error('SSH connection error:', err); + reject(err); + }); + + ssh.on('ready', () => { + ssh.forwardOut( + '127.0.0.1', + 0, + dbConfig.host, + dbConfig.port, + (err, stream) => { + if (err) reject(err); + resolve({ ssh, stream, dbConfig }); + } + ); + }).connect(sshConfig); + }); +} + +/** + * Clear cached query results + * @param {string} [cacheKey] - Specific cache key to clear (clears all if not provided) + */ +function clearQueryCache(cacheKey) { + if (cacheKey) { + connectionCache.queryCache.delete(cacheKey); + console.log(`Cleared cache for key: ${cacheKey}`); + } else { + connectionCache.queryCache.clear(); + console.log('Cleared all query cache'); + } +} + +/** + * Force close all active connections + * Useful for server shutdown or manual connection reset + */ +async function closeAllConnections() { + if (connectionCache.dbConnection) { + try { + await connectionCache.dbConnection.end(); + console.log('Closed database connection'); + } catch (error) { + console.error('Error closing database connection:', error); + } + connectionCache.dbConnection = null; + } + + if (connectionCache.ssh) { + try { + connectionCache.ssh.end(); + console.log('Closed SSH connection'); + } catch (error) { + console.error('Error closing SSH connection:', error); + } + connectionCache.ssh = null; + } + + connectionCache.lastUsed = 0; + connectionCache.isConnecting = false; + connectionCache.connectionPromise = null; +} + +module.exports = { + getDbConnection, + getCachedQuery, + clearQueryCache, + closeAllConnections +}; \ No newline at end of file