diff --git a/dashboard-server/._gorgias-server b/dashboard-server/._gorgias-server new file mode 100644 index 0000000..84512ab Binary files /dev/null and b/dashboard-server/._gorgias-server differ diff --git a/dashboard-server/ecosystem.config.cjs b/dashboard-server/ecosystem.config.cjs index a95fb37..a09c685 100644 --- a/dashboard-server/ecosystem.config.cjs +++ b/dashboard-server/ecosystem.config.cjs @@ -129,6 +129,22 @@ module.exports = { NODE_ENV: 'production', PORT: 3005 } + }, + { + name: "gorgias-server", + script: "./gorgias-server/server.js", + env: { + NODE_ENV: "development", + PORT: 3006 + }, + env_production: { + NODE_ENV: "production", + PORT: 3006 + }, + error_file: "./logs/gorgias-server-error.log", + out_file: "./logs/gorgias-server-out.log", + log_file: "./logs/gorgias-server-combined.log", + time: true } ] }; diff --git a/dashboard-server/gorgias-server/._.env b/dashboard-server/gorgias-server/._.env new file mode 100644 index 0000000..84512ab Binary files /dev/null and b/dashboard-server/gorgias-server/._.env differ diff --git a/dashboard-server/gorgias-server/._server.js b/dashboard-server/gorgias-server/._server.js new file mode 100644 index 0000000..84512ab Binary files /dev/null and b/dashboard-server/gorgias-server/._server.js differ diff --git a/dashboard-server/gorgias-server/package-lock.json b/dashboard-server/gorgias-server/package-lock.json new file mode 100644 index 0000000..5b7d48a --- /dev/null +++ b/dashboard-server/gorgias-server/package-lock.json @@ -0,0 +1,1036 @@ +{ + "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.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "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/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.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "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/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/dashboard-server/gorgias-server/package.json b/dashboard-server/gorgias-server/package.json new file mode 100644 index 0000000..6240316 --- /dev/null +++ b/dashboard-server/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/dashboard-server/gorgias-server/routes/._gorgias.routes.js b/dashboard-server/gorgias-server/routes/._gorgias.routes.js new file mode 100644 index 0000000..84512ab Binary files /dev/null and b/dashboard-server/gorgias-server/routes/._gorgias.routes.js differ diff --git a/dashboard-server/gorgias-server/routes/gorgias.routes.js b/dashboard-server/gorgias-server/routes/gorgias.routes.js new file mode 100644 index 0000000..3a6199d --- /dev/null +++ b/dashboard-server/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/dashboard-server/gorgias-server/server.js b/dashboard-server/gorgias-server/server.js new file mode 100644 index 0000000..f0c05c4 --- /dev/null +++ b/dashboard-server/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/dashboard-server/gorgias-server/services/._gorgias.service.js b/dashboard-server/gorgias-server/services/._gorgias.service.js new file mode 100644 index 0000000..84512ab Binary files /dev/null and b/dashboard-server/gorgias-server/services/._gorgias.service.js differ diff --git a/dashboard-server/gorgias-server/services/gorgias.service.js b/dashboard-server/gorgias-server/services/gorgias.service.js new file mode 100644 index 0000000..a34a41e --- /dev/null +++ b/dashboard-server/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/dashboard/src/App.jsx b/dashboard/src/App.jsx index 1823152..1430fcf 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -23,6 +23,7 @@ import ProductGrid from "./components/dashboard/ProductGrid"; import SalesChart from "./components/dashboard/SalesChart"; import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns"; import MetaCampaigns from "@/components/dashboard/MetaCampaigns"; +import GorgiasOverview from "@/components/dashboard/GorgiasOverview"; // Public layout const PublicLayout = () => ( @@ -90,7 +91,7 @@ const DashboardLayout = () => {
- +
diff --git a/dashboard/src/components/dashboard/GorgiasOverview.jsx b/dashboard/src/components/dashboard/GorgiasOverview.jsx new file mode 100644 index 0000000..614eb75 --- /dev/null +++ b/dashboard/src/components/dashboard/GorgiasOverview.jsx @@ -0,0 +1,477 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Clock, + Star, + Users, + MessageSquare, + Mail, + Send, + Loader2, +} from "lucide-react"; +import axios from "axios"; + +const TIME_RANGES = { + 7: "Last 7 Days", + 14: "Last 14 Days", + 30: "Last 30 Days", + 90: "Last 90 Days", +}; + +const formatDuration = (seconds) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +}; + +const getDateRange = (days) => { + const end = new Date(); + end.setUTCHours(23, 59, 59, 999); + + const start = new Date(); + start.setDate(start.getDate() - days); + start.setUTCHours(0, 0, 0, 0); + + return { + start_datetime: start.toISOString(), + end_datetime: end.toISOString() + }; +}; + +const MetricCard = ({ + title, + value, + delta, + suffix = "", + icon: Icon, + colorClass = "blue", + more_is_better = true, + loading = false, +}) => { + const getDeltaColor = (d) => { + if (d === 0) return "text-gray-600 dark:text-gray-400"; + const isPositive = d > 0; + return isPositive === more_is_better + ? "text-green-600 dark:text-green-500" + : "text-red-600 dark:text-red-500"; + }; + + const formatDelta = (d) => { + if (d === undefined || d === null) return null; + if (d === 0) return "0"; + return (d > 0 ? "+" : "") + d + suffix; + }; + + const colorMapping = { + blue: "bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800/50 text-blue-600 dark:text-blue-400", + green: "bg-green-50 dark:bg-green-900/20 border-green-100 dark:border-green-800/50 text-green-600 dark:text-green-400", + purple: "bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800/50 text-purple-600 dark:text-purple-400", + indigo: "bg-indigo-50 dark:bg-indigo-900/20 border-indigo-100 dark:border-indigo-800/50 text-indigo-600 dark:text-indigo-400", + orange: "bg-orange-50 dark:bg-orange-900/20 border-orange-100 dark:border-orange-800/50 text-orange-600 dark:text-orange-400", + teal: "bg-teal-50 dark:bg-teal-900/20 border-teal-100 dark:border-teal-800/50 text-teal-600 dark:text-teal-400", + cyan: "bg-cyan-50 dark:bg-cyan-900/20 border-cyan-100 dark:border-cyan-800/50 text-cyan-600 dark:text-cyan-400", + }; + + const baseColors = colorMapping[colorClass]; + + return ( +
+

+ {title} +

+
+ {loading ? ( + + ) : ( + <> +
+ {Icon && } +

+ {typeof value === "number" + ? value.toLocaleString() + suffix + : value} +

+
+ {delta !== undefined && ( +

+ {formatDelta(delta)} +

+ )} + + )} +
+
+ ); +}; + +const TableSkeleton = () => ( +
+ + + + +
+); + +const GorgiasOverview = () => { + const [timeRange, setTimeRange] = useState(7); + const [data, setData] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadStats = useCallback(async () => { + setLoading(true); + const filters = getDateRange(timeRange); + + try { + const [overview, channelStats, agentStats, satisfaction, selfService] = + await Promise.all([ + axios.post('/api/gorgias/stats/overview', filters) + .then(res => res.data?.data?.data?.data || []), + axios.post('/api/gorgias/stats/tickets-created-per-channel', filters) + .then(res => res.data?.data?.data?.data?.lines || []), + axios.post('/api/gorgias/stats/tickets-closed-per-agent', filters) + .then(res => res.data?.data?.data?.data?.lines || []), + axios.post('/api/gorgias/stats/satisfaction-surveys', filters) + .then(res => res.data?.data?.data?.data || []), + axios.post('/api/gorgias/stats/self-service-overview', filters) + .then(res => res.data?.data?.data?.data || []), + ]); + + console.log('Raw API responses:', { + overview, + channelStats, + agentStats, + satisfaction, + selfService + }); + + setData({ + overview, + channels: channelStats, + agents: agentStats, + satisfaction, + selfService, + }); + + setError(null); + } catch (err) { + console.error("Error loading stats:", err); + const errorMessage = err.response?.data?.error || err.message; + setError(errorMessage); + if (err.response?.status === 401) { + setError('Authentication failed. Please check your Gorgias API credentials.'); + } + } finally { + setLoading(false); + } + }, [timeRange]); + + useEffect(() => { + loadStats(); + // Set up auto-refresh every 5 minutes + const interval = setInterval(loadStats, 5 * 60 * 1000); + return () => clearInterval(interval); + }, [loadStats]); + + // Convert overview array to stats format + const stats = (data.overview || []).reduce((acc, item) => { + acc[item.name] = { + value: item.value || 0, + delta: item.delta || 0, + type: item.type, + more_is_better: item.more_is_better + }; + return acc; + }, {}); + + console.log('Processed stats:', stats); + + // Process satisfaction data + const satisfactionStats = (data.satisfaction || []).reduce((acc, item) => { + if (item.name !== 'response_distribution') { + acc[item.name] = { + value: item.value || 0, + delta: item.delta || 0, + type: item.type, + more_is_better: item.more_is_better + }; + } + return acc; + }, {}); + + console.log('Processed satisfaction stats:', satisfactionStats); + + // Process self-service data + const selfServiceStats = (data.selfService || []).reduce((acc, item) => { + acc[item.name] = { + value: item.value || 0, + delta: item.delta || 0, + type: item.type, + more_is_better: item.more_is_better + }; + return acc; + }, {}); + + console.log('Processed self-service stats:', selfServiceStats); + + // Process channel data + const channels = data.channels?.map(line => ({ + name: line[0]?.value || '', + total: line[1]?.value || 0, + percentage: line[2]?.value || 0, + delta: line[3]?.value || 0 + })) || []; + + console.log('Processed channels:', channels); + + // Process agent data + const agents = data.agents?.map(line => ({ + name: line[0]?.value || '', + closed: line[1]?.value || 0, + rating: line[2]?.value, + percentage: line[3]?.value || 0, + delta: line[4]?.value || 0 + })) || []; + + console.log('Processed agents:', agents); + + if (error) return

Error: {error}

; + + return ( + + +
+

+ Customer Service +

+
+ +
+
+
+ + +
+ {/* Message & Response Metrics */} + + + + +
+ +
+ {/* Satisfaction & Efficiency */} + + + + +
+ +
+ {/* Channel Distribution */} +
+
+

+ Channel Distribution +

+
+ {loading ? ( +
+ +
+ ) : ( + + + + Channel + Total + % + Δ + + + + {channels + .sort((a, b) => b.total - a.total) + .map((channel, index) => ( + + + {channel.name} + + + {channel.total} + + + {channel.percentage}% + + 0 + ? "text-green-600 dark:text-green-500" + : channel.delta < 0 + ? "text-red-600 dark:text-red-500" + : "dark:text-gray-300" + }`} + > + {channel.delta > 0 ? "+" : ""} + {channel.delta} + + + ))} + +
+ )} +
+ + {/* Agent Performance */} +
+
+

+ Agent Performance +

+
+ {loading ? ( +
+ +
+ ) : ( + + + + Agent + Closed + Rating + Δ + + + + {agents + .filter((agent) => agent.name !== "Unassigned") + .map((agent, index) => ( + + + {agent.name} + + + {agent.closed} + + + {agent.rating ? `${agent.rating}/5` : "-"} + + 0 + ? "text-green-600 dark:text-green-500" + : agent.delta < 0 + ? "text-red-600 dark:text-red-500" + : "dark:text-gray-300" + }`} + > + {agent.delta > 0 ? "+" : ""} + {agent.delta} + + + ))} + +
+ )} +
+
+
+
+ ); +}; + +export default GorgiasOverview; \ No newline at end of file diff --git a/dashboard/src/lib/api.js b/dashboard/src/lib/api.js new file mode 100644 index 0000000..0a5f7e3 --- /dev/null +++ b/dashboard/src/lib/api.js @@ -0,0 +1,15 @@ +import axios from 'axios'; + +// Create base64 encoded auth string +const auth = Buffer.from(`${process.env.REACT_APP_GORGIAS_API_USERNAME}:${process.env.REACT_APP_GORGIAS_API_KEY}`).toString('base64'); + +// Create axios instance for Gorgias API +const gorgiasApi = axios.create({ + baseURL: `https://${process.env.REACT_APP_GORGIAS_DOMAIN}.gorgias.com/api`, + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json' + } +}); + +export { gorgiasApi }; \ No newline at end of file diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js index 4df0703..3592a7b 100644 --- a/dashboard/vite.config.js +++ b/dashboard/vite.config.js @@ -167,6 +167,42 @@ export default defineConfig(({ mode }) => { ); }); }, + }, + "/api/gorgias": { + target: "https://dashboard.kent.pw", + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/api\/gorgias/, "/api/gorgias"), + configure: (proxy, _options) => { + proxy.on("error", (err, req, res) => { + console.error("Gorgias proxy error:", err); + res.writeHead(500, { + "Content-Type": "application/json", + }); + res.end( + JSON.stringify({ + error: "Proxy Error", + message: err.message, + details: err.stack + }) + ); + }); + proxy.on("proxyReq", (proxyReq, req, _res) => { + console.log("Outgoing Gorgias request:", { + method: req.method, + url: req.url, + path: proxyReq.path, + headers: proxyReq.getHeaders(), + }); + }); + proxy.on("proxyRes", (proxyRes, req, _res) => { + console.log("Gorgias proxy response:", { + statusCode: proxyRes.statusCode, + url: req.url, + headers: proxyRes.headers, + }); + }); + }, } }, }, diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY AnalyticsDashboard.jsx b/examples DO NOT USE OR EDIT/EXAMPLE ONLY AnalyticsDashboard.jsx new file mode 100644 index 0000000..1e9e7aa --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY AnalyticsDashboard.jsx @@ -0,0 +1,365 @@ +//src/components/dashboard/AnalyticsDashboard.jsx +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; +import { Loader2 } from "lucide-react"; +import { googleAnalyticsService } from "../../services/googleAnalyticsService"; + +export const AnalyticsDashboard = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [timeRange, setTimeRange] = useState("30"); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const result = await googleAnalyticsService.getBasicMetrics({ + startDate: `${timeRange}daysAgo`, + }); + + if (result) { + const processedData = result.map((item) => ({ + ...item, + date: formatGADate(item.date), + })); + + const sortedData = processedData.sort((a, b) => a.date - b.date); + + setData(sortedData); + } else { + console.log("No result data received"); + } + } catch (error) { + console.error("Failed to fetch analytics:", error); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [timeRange]); + const formatGADate = (gaDate) => { + const year = gaDate.substring(0, 4); + const month = gaDate.substring(4, 6); + const day = gaDate.substring(6, 8); + return new Date(year, month - 1, day); + }; + + const [selectedMetrics, setSelectedMetrics] = useState({ + activeUsers: true, + newUsers: true, + pageViews: true, + conversions: true, + }); + + const MetricToggle = ({ label, checked, onChange }) => ( +
+ + +
+ ); + + const CustomLegend = ({ metrics, selectedMetrics }) => { + // Separate items for left and right axes + const leftAxisItems = Object.entries(metrics).filter( + ([key, metric]) => metric.yAxis === "left" && selectedMetrics[key] + ); + + const rightAxisItems = Object.entries(metrics).filter( + ([key, metric]) => metric.yAxis === "right" && selectedMetrics[key] + ); + + return ( +
+
+

+ Left Axis +

+ {leftAxisItems.map(([key, metric]) => ( +
+
+ + {metric.label} + +
+ ))} +
+
+

+ Right Axis +

+ {rightAxisItems.map(([key, metric]) => ( +
+
+ + {metric.label} + +
+ ))} +
+
+ ); + }; + + const metrics = { + activeUsers: { label: "Active Users", color: "#8b5cf6" }, + newUsers: { label: "New Users", color: "#10b981" }, + pageViews: { label: "Page Views", color: "#f59e0b" }, + conversions: { label: "Conversions", color: "#3b82f6" }, + }; + + const calculateSummary = () => { + if (!data.length) return null; + + const totals = data.reduce( + (acc, day) => ({ + activeUsers: acc.activeUsers + (Number(day.activeUsers) || 0), + newUsers: acc.newUsers + (Number(day.newUsers) || 0), + pageViews: acc.pageViews + (Number(day.pageViews) || 0), + conversions: acc.conversions + (Number(day.conversions) || 0), + avgSessionDuration: + acc.avgSessionDuration + (Number(day.avgSessionDuration) || 0), + bounceRate: acc.bounceRate + (Number(day.bounceRate) || 0), + }), + { + activeUsers: 0, + newUsers: 0, + pageViews: 0, + conversions: 0, + avgSessionDuration: 0, + bounceRate: 0, + } + ); + + return { + ...totals, + avgSessionDuration: totals.avgSessionDuration / data.length, + bounceRate: totals.bounceRate / data.length, + }; + }; + + const summary = calculateSummary(); + + if (loading) { + return ( + + + + + + ); + } + + const formatXAxisDate = (date) => { + if (!(date instanceof Date)) return ""; + return `${date.getMonth() + 1}/${date.getDate()}`; + }; + + const CustomTooltip = ({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( +
+

+ {label instanceof Date ? label.toLocaleDateString() : label} +

+ {payload.map((entry, index) => ( +

+ {`${entry.name}: ${Number(entry.value).toLocaleString()}`} +

+ ))} +
+ ); + } + return null; + }; + + return ( + + +
+ + Analytics Overview + + +
+ + {summary && ( +
+
+
+ Total Users +
+
+ {summary.activeUsers.toLocaleString()} +
+
+
+
+ New Users +
+
+ {summary.newUsers.toLocaleString()} +
+
+
+
+ Page Views +
+
+ {summary.pageViews.toLocaleString()} +
+
+
+
+ Conversions +
+
+ {summary.conversions.toLocaleString()} +
+
+
+ )} + +
+ + setSelectedMetrics((prev) => ({ ...prev, activeUsers: checked })) + } + /> + + setSelectedMetrics((prev) => ({ ...prev, newUsers: checked })) + } + /> + + setSelectedMetrics((prev) => ({ ...prev, pageViews: checked })) + } + /> + + setSelectedMetrics((prev) => ({ ...prev, conversions: checked })) + } + /> +
+
+ + +
+ + + + + + + } /> + ( + + {value} + + )} + /> + {/* Always render Lines and control visibility */} + {Object.entries(metrics).map(([key, { color, label }]) => ( + + ))} + + +
+
+
+ ); +}; +export default AnalyticsDashboard; \ No newline at end of file diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY GorgiasSummary.jsx b/examples DO NOT USE OR EDIT/EXAMPLE ONLY GorgiasSummary.jsx new file mode 100644 index 0000000..c573aaa --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY GorgiasSummary.jsx @@ -0,0 +1,401 @@ +import React, { useState, useEffect, useCallback } from "react"; +import gorgiasService from "../../services/gorgiasService"; +import { getDateRange } from "../../utils/dateUtils"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Clock, Star, Users, MessageSquare, Mail, Send } from "lucide-react"; + +const TIME_RANGES = { + 7: "Last 7 Days", + 14: "Last 14 Days", + 30: "Last 30 Days", + 90: "Last 90 Days", +}; + +const formatDuration = (seconds) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +}; + +const MetricCard = ({ + title, + value, + delta, + suffix = "", + icon: Icon, + colorClass = "blue", + more_is_better = true, + loading = false, +}) => { + const getDeltaColor = (d) => { + if (d === 0) return "text-gray-600 dark:text-gray-400"; + const isPositive = d > 0; + return isPositive === more_is_better + ? "text-green-600 dark:text-green-500" + : "text-red-600 dark:text-red-500"; + }; + + const formatDelta = (d) => { + if (d === undefined || d === null) return null; + if (d === 0) return "0"; + return (d > 0 ? "+" : "") + d + suffix; + }; + + const colorMapping = { + blue: "bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800/50 text-blue-600 dark:text-blue-400", + green: + "bg-green-50 dark:bg-green-900/20 border-green-100 dark:border-green-800/50 text-green-600 dark:text-green-400", + purple: + "bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800/50 text-purple-600 dark:text-purple-400", + indigo: + "bg-indigo-50 dark:bg-indigo-900/20 border-indigo-100 dark:border-indigo-800/50 text-indigo-600 dark:text-indigo-400", + orange: + "bg-orange-50 dark:bg-orange-900/20 border-orange-100 dark:border-orange-800/50 text-orange-600 dark:text-orange-400", + teal: "bg-teal-50 dark:bg-teal-900/20 border-teal-100 dark:border-teal-800/50 text-teal-600 dark:text-teal-400", + cyan: "bg-cyan-50 dark:bg-cyan-900/20 border-cyan-100 dark:border-cyan-800/50 text-cyan-600 dark:text-cyan-400", + }; + + const baseColors = colorMapping[colorClass]; + + return ( +
+

+ {title} +

+
+ {loading ? ( + + ) : ( + <> +
+ {Icon && } +

+ {typeof value === "number" + ? value.toLocaleString() + suffix + : value} +

+
+ {delta !== undefined && ( +

+ {formatDelta(delta)} +

+ )} + + )} +
+
+ ); +}; + +const TableSkeleton = () => ( +
+ + + + +
+); + +const GorgiasSummary = () => { + const [timeRange, setTimeRange] = useState(7); + const [data, setData] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadStats = useCallback(async () => { + setLoading(true); + const filters = getDateRange(timeRange); + + try { + const [overview, channelStats, agentStats, satisfaction, selfService] = + await Promise.all([ + gorgiasService.fetchStatistics("overview", filters), + gorgiasService.fetchStatistics( + "tickets-created-per-channel", + filters + ), + gorgiasService.fetchStatistics("tickets-closed-per-agent", filters), + gorgiasService.fetchStatistics("satisfaction-surveys", filters), + gorgiasService.fetchStatistics("self-service-overview", filters), + ]); + + setData({ + overview: overview.data.data.data || [], + channels: channelStats.data.data.data.lines || [], + agents: agentStats.data.data.data.lines || [], + satisfaction: satisfaction.data.data.data || [], + selfService: selfService.data.data.data || [], + }); + setError(null); + } catch (err) { + console.error("Error loading stats:", err); + setError(err.message); + } finally { + setLoading(false); + } + }, [timeRange]); + + useEffect(() => { + loadStats(); + // Set up auto-refresh every 5 minutes + const interval = setInterval(loadStats, 5 * 60 * 1000); + return () => clearInterval(interval); + }, [loadStats]); + + // Convert overview array to object for easier access + const stats = + data.overview?.reduce((acc, item) => { + acc[item.name] = item; + return acc; + }, {}) || {}; + + // Process satisfaction data + const satisfactionStats = + data.satisfaction?.reduce((acc, item) => { + acc[item.name] = item; + return acc; + }, {}) || {}; + + // Process self-service data + const selfServiceStats = + data.selfService?.reduce((acc, item) => { + acc[item.name] = item; + return acc; + }, {}) || {}; + + if (error) return

Error: {error}

; + + return ( + + +
+

+ Customer Service +

+
+ +
+
+
+ + +
+ {/* Message & Response Metrics */} + + + + +
+ +
+ {/* Satisfaction & Efficiency */} + + + + +
+ +
+ {/* Channel Distribution */} +
+
+

+ Channel Distribution +

+
+ {loading ? ( +
+ +
+ ) : ( + + + + Channel + Total + % + Δ + + + + {data.channels + .sort((a, b) => b[1].value - a[1].value) + .map((line, index) => ( + + + {line[0].value} + + + {line[1].value} + + + {line[2].value}% + + 0 + ? "text-green-600 dark:text-green-500" + : line[3].value < 0 + ? "text-red-600 dark:text-red-500" + : "dark:text-gray-300" + }`} + > + {line[3].value > 0 ? "+" : ""} + {line[3].value} + + + ))} + +
+ )} +
+ + {/* Agent Performance - Same dark mode updates */} +
+
+

+ Agent Performance +

+
+ {loading ? ( +
+ +
+ ) : ( + + + + Agent + Closed + Rating + Δ + + + + {data.agents + .filter((line) => line[0].value !== "Unassigned") + .map((line, index) => ( + + + {line[0].value} + + + {line[1].value} + + + {line[2].value ? `${line[2].value}/5` : "-"} + + 0 + ? "text-green-600 dark:text-green-500" + : line[4].value < 0 + ? "text-red-600 dark:text-red-500" + : "dark:text-gray-300" + }`} + > + {line[4].value > 0 ? "+" : ""} + {line[4].value} + + + ))} + +
+ )} +
+
+
+
+ ); +}; + +export default GorgiasSummary; diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY RealtimeAnalytics.jsx b/examples DO NOT USE OR EDIT/EXAMPLE ONLY RealtimeAnalytics.jsx new file mode 100644 index 0000000..c47e92b --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY RealtimeAnalytics.jsx @@ -0,0 +1,529 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, +} from "recharts"; +import { Loader2, AlertTriangle } from "lucide-react"; +import { + Tooltip as UITooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "@/components/ui/tooltip"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, + TableHeader, + TableHead, + TableBody, + TableRow, + TableCell, +} from "@/components/ui/table"; +import { googleAnalyticsService } from "../../services/googleAnalyticsService"; +import { format } from "date-fns"; + +const formatNumber = (value, decimalPlaces = 0) => { + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }).format(value || 0); +}; + +const formatPercent = (value, decimalPlaces = 1) => + `${(value || 0).toFixed(decimalPlaces)}%`; + +const summaryCard = (label, sublabel, value, options = {}) => { + const { + isMonetary = false, + isPercentage = false, + decimalPlaces = 0, + } = options; + + let displayValue; + if (isMonetary) { + displayValue = formatCurrency(value, decimalPlaces); + } else if (isPercentage) { + displayValue = formatPercent(value, decimalPlaces); + } else { + displayValue = formatNumber(value, decimalPlaces); + } + + return ( +
+
+ {label} +
+
+ {displayValue} +
+
{sublabel}
+
+ ); +}; +const QuotaInfo = ({ tokenQuota }) => { + // Add early return if tokenQuota is null or undefined + if (!tokenQuota || typeof tokenQuota !== "object") return null; + + const { + projectHourly = {}, + daily = {}, + serverErrors = {}, + thresholdedRequests = {}, + } = tokenQuota; + + // Add null checks and default values for all properties + const { + remaining: projectHourlyRemaining = 0, + consumed: projectHourlyConsumed = 0, + } = projectHourly; + + const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily; + + const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } = + serverErrors; + + const { + remaining: thresholdRemaining = 120, + consumed: thresholdConsumed = 0, + } = thresholdedRequests; + + // Calculate percentages with safe math + const hourlyPercentage = ((projectHourlyRemaining / 14000) * 100).toFixed(1); + const dailyPercentage = ((dailyRemaining / 200000) * 100).toFixed(1); + const errorPercentage = ((errorsRemaining / 10) * 100).toFixed(1); + const thresholdPercentage = ((thresholdRemaining / 120) * 100).toFixed(1); + + // Determine color based on remaining percentage + const getStatusColor = (percentage) => { + const numericPercentage = parseFloat(percentage); + if (isNaN(numericPercentage) || numericPercentage < 20) + return "text-red-500 dark:text-red-400"; + if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400"; + return "text-green-500 dark:text-green-400"; + }; + + return ( + + + +
+ Quota: + + {hourlyPercentage}% + +
+
+ +
+
+
+ Project Hourly +
+
+ {projectHourlyRemaining.toLocaleString()} / 14,000 remaining +
+
+
+
+ Daily +
+
+ {dailyRemaining.toLocaleString()} / 200,000 remaining +
+
+
+
+ Server Errors +
+
+ {errorsConsumed} / 10 used this hour +
+
+
+
+ Thresholded Requests +
+
+ {thresholdConsumed} / 120 used this hour +
+
+
+
+
+
+ ); +}; +const RealtimeAnalytics = () => { + const [basicData, setBasicData] = useState({ + last30MinUsers: 0, + last5MinUsers: 0, + byMinute: [], + tokenQuota: null, + lastUpdated: null, + }); + + const [detailedData, setDetailedData] = useState({ + currentPages: [], + sources: [], + recentEvents: [], + lastUpdated: null, + }); + const [loading, setLoading] = useState(true); + const [isPaused, setIsPaused] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let basicInterval; + let detailedInterval; + + const fetchBasicData = async () => { + if (isPaused) return; + try { + const response = await fetch("/api/analytics/realtime/basic", { + credentials: "include", + }); + const result = await response.json(); + + const processed = await googleAnalyticsService.getRealTimeBasicData(); + setBasicData(processed); + setError(null); + } catch (error) { + console.error("Error details:", { + message: error.message, + stack: error.stack, + response: error.response, + }); + if (error.message === "QUOTA_EXCEEDED") { + setError("Quota exceeded. Analytics paused until manually resumed."); + setIsPaused(true); + } else { + setError("Failed to fetch analytics data"); + } + } + }; + const fetchDetailedData = async () => { + if (isPaused) return; + try { + const result = await googleAnalyticsService.getRealTimeDetailedData(); + setDetailedData(result); + } catch (error) { + console.error("Failed to fetch detailed realtime data:", error); + if (error.message === "QUOTA_EXCEEDED") { + setError("Quota exceeded. Analytics paused until manually resumed."); + setIsPaused(true); + } else { + setError("Failed to fetch analytics data"); + } + } finally { + setLoading(false); + } + }; + + // Initial fetches + fetchBasicData(); + fetchDetailedData(); + + // Set up intervals + basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds + detailedInterval = setInterval(fetchDetailedData, 300000); // 5 minutes + + return () => { + clearInterval(basicInterval); + clearInterval(detailedInterval); + }; + }, []); + + const togglePause = () => { + setIsPaused(!isPaused); + }; + + if (loading && !basicData && !detailedData) { + return ( + + + + + + ); + } + + // Pie chart colors + const COLORS = [ + "#8b5cf6", + "#10b981", + "#f59e0b", + "#3b82f6", + "#0088FE", + "#00C49F", + "#FFBB28", + ]; + + // Handle 'other' in data + const totalUsers = detailedData.sources.reduce( + (sum, source) => sum + source.users, + 0 + ); + const sourcesData = detailedData.sources.map((source) => { + const percent = (source.users / totalUsers) * 100; + return { ...source, percent }; + }); + + return ( + + +
+ + Realtime Analytics + +
+ {basicData?.data?.quotaInfo && ( + + )} +
+ Last updated:{" "} + {basicData.lastUpdated + ? format(new Date(basicData.lastUpdated), "p") + : "N/A"} +
+
{" "} +
+ + {error && ( + + + {error} + + )} + + {/* Summary Cards */} +
+ {[ + { + label: "Last 30 Minutes", + sublabel: "Active Users", + value: basicData.last30MinUsers, + }, + { + label: "Last 5 Minutes", + sublabel: "Active Users", + value: basicData.last5MinUsers, + }, + ].map((card) => ( +
+ {summaryCard(card.label, card.sublabel, card.value)} +
+ ))} +
+
+ + + {/* User Activity Chart */} +
+ + + + Active Users Per Minute + + + +
+ + + { + if (active && payload && payload.length) { + return ( +
+

{`${payload[0].value} active users`}

+

+ {payload[0].payload.timestamp} +

+
+ ); + } + return null; + }} + />{" "} + +
+
+
+
+
+
+ + {/* Tabs for Detailed Data */} + + + Current Pages + Recent Events + Active Devices + + + {/* Current Pages Tab */} + +
+ Last updated:{" "} + {detailedData.lastUpdated + ? format(new Date(detailedData.lastUpdated), "p") + : "N/A"} +
+ + + + + Page + + + Views + + + + + {detailedData.currentPages.map(({ page, views }, index) => ( + + + {page} + + + {formatNumber(views)} + + + ))} + +
+
+ {/* Recent Events Tab */} + +
+ Last updated:{" "} + {detailedData.lastUpdated + ? format(new Date(detailedData.lastUpdated), "p") + : "N/A"} +
+ + + + + Event + + + Count + + + + + {detailedData.recentEvents.map(({ event, count }, index) => ( + + + {event} + + + {formatNumber(count)} + + + ))} + +
+
+ {/* Active Devices Tab */} + + {" "} +
+ Last updated:{" "} + {detailedData.lastUpdated + ? format(new Date(detailedData.lastUpdated), "p") + : "N/A"} +
+
+ + + + `${device}: ${percent.toFixed(0)}%` + } + > + {sourcesData.map((entry, index) => ( + + ))} + + { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0].payload.device} +

+

{`${formatNumber( + payload[0].value + )} users`}

+
+ ); + } + return null; + }} + /> +
{" "} +
+
+
+ {sourcesData.map((source, index) => ( +
+
+ {source.device} +
+
+
+ {formatNumber(source.users)} +
+
+ {source.percent.toFixed(0)}% of users +
+
+
+ ))} +
+
+
+
+
+ ); +}; + +export default RealtimeAnalytics; diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY UserBehaviorDashboard.jsx b/examples DO NOT USE OR EDIT/EXAMPLE ONLY UserBehaviorDashboard.jsx new file mode 100644 index 0000000..d3ec56d --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY UserBehaviorDashboard.jsx @@ -0,0 +1,297 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + Legend, +} from "recharts"; +import { Loader2 } from "lucide-react"; +import { googleAnalyticsService } from "../../services/googleAnalyticsService"; + +export const UserBehaviorDashboard = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [timeRange, setTimeRange] = useState("30"); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const result = await googleAnalyticsService.getUserBehavior(timeRange); + if (result) { + setData(result); + } + } catch (error) { + console.error("Failed to fetch behavior data:", error); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [timeRange]); + + if (loading) { + return ( + + + + + + ); + } + + const COLORS = { + desktop: "#8b5cf6", // Purple + mobile: "#10b981", // Green + tablet: "#f59e0b", // Yellow + }; + + const deviceData = data?.data?.pageData?.deviceData || []; + const totalViews = deviceData.reduce((sum, item) => sum + item.pageViews, 0); + const totalSessions = deviceData.reduce( + (sum, item) => sum + item.sessions, + 0 + ); + + const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + const percentage = ((data.pageViews / totalViews) * 100).toFixed(1); + const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed( + 1 + ); + return ( +
+

+ {data.device} +

+

+ {data.pageViews.toLocaleString()} views ({percentage}%) +

+

+ {data.sessions.toLocaleString()} sessions ({sessionPercentage}%) +

+
+ ); + } + return null; + }; + + const formatDuration = (seconds) => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; + }; + + return ( + + +
+ + User Behavior Analysis + + +
+
+ + + + Top Pages + Traffic Sources + Device Usage + + + + + + + + Page Path + + + Views + + + Bounce Rate + + + Avg. Duration + + + + + {data?.data?.pageData?.pageData.map((page, index) => ( + + + {page.path} + + + {page.pageViews.toLocaleString()} + + + {page.bounceRate.toFixed(1)}% + + + {formatDuration(page.avgSessionDuration)} + + + ))} + +
+
+ + + + + + + Source + + + Sessions + + + Conversions + + + Conv. Rate + + + + + {data?.data?.sourceData?.map((source, index) => ( + + + {source.source} + + + {source.sessions.toLocaleString()} + + + {source.conversions.toLocaleString()} + + + {((source.conversions / source.sessions) * 100).toFixed( + 1 + )} + % + + + ))} + +
+
+ + +
+ + + + `${name} ${(percent * 100).toFixed(1)}%` + } + > + {deviceData.map((entry, index) => ( + + ))} + + } /> + ( + + {value} + + )} + /> + + +
+
+ {deviceData.map((device) => ( +
+
+ {device.device} +
+
+
+ {device.pageViews.toLocaleString()} +
+
+ {((device.pageViews / totalViews) * 100).toFixed(1)}% of + views +
+
+
+
+ {device.sessions.toLocaleString()} +
+
+ {((device.sessions / totalSessions) * 100).toFixed(1)}% of + sessions +
+
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY analytics.service.js b/examples DO NOT USE OR EDIT/EXAMPLE ONLY analytics.service.js new file mode 100644 index 0000000..41c370c --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY analytics.service.js @@ -0,0 +1,338 @@ +// services/analytics.service.js +const { BetaAnalyticsDataClient } = require('@google-analytics/data'); +const Analytics = require('../models/analytics.model'); +const { createClient } = require('redis'); +const logger = require('../utils/logger'); + +class AnalyticsService { + constructor() { + // Initialize Redis client + this.redis = createClient({ + url: process.env.REDIS_URL + }); + + this.redis.on('error', err => logger.error('Redis Client Error:', err)); + this.redis.connect().catch(err => logger.error('Redis connection error:', err)); + + // Initialize GA4 client + this.analyticsClient = new BetaAnalyticsDataClient({ + credentials: JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON) + }); + + this.propertyId = process.env.GA_PROPERTY_ID; + } + + async getBasicMetrics(params = {}) { + const cacheKey = `analytics:basic_metrics:${JSON.stringify(params)}`; + logger.info(`Fetching basic metrics with params:`, params); + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + logger.info('Analytics metrics found in Redis cache'); + return JSON.parse(cachedData); + } + + // Check MongoDB using new findValidCache method + const mongoData = await Analytics.findValidCache('basic_metrics', params); + + if (mongoData) { + logger.info('Analytics metrics found in MongoDB'); + const formattedData = mongoData.formatResponse(); + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Analytics.getCacheDuration('basic_metrics') + }); + return formattedData; + } + + // Fetch fresh data from GA4 + logger.info('Fetching fresh metrics data from GA4'); + const [response] = await this.analyticsClient.runReport({ + property: `properties/${this.propertyId}`, + dateRanges: [{ + startDate: params.startDate || '7daysAgo', + endDate: 'today' + }], + dimensions: [{ name: 'date' }], + metrics: [ + { name: 'activeUsers' }, + { name: 'newUsers' }, + { name: 'averageSessionDuration' }, + { name: 'screenPageViews' }, + { name: 'bounceRate' }, + { name: 'conversions' } + ], + returnPropertyQuota: true + }); + + // Create new Analytics document with fresh data + const analyticsDoc = await Analytics.create({ + type: 'basic_metrics', + params, + data: response, + quotaInfo: response.propertyQuota + }); + + const formattedData = analyticsDoc.formatResponse(); + + // Save to Redis + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Analytics.getCacheDuration('basic_metrics') + }); + + return formattedData; + } catch (error) { + logger.error('Error fetching analytics metrics:', { + error: error.message, + stack: error.stack + }); + throw error; + } + } + + async getRealTimeBasicData() { + const cacheKey = 'analytics:realtime:basic'; + logger.info('Fetching realtime basic data'); + + try { + // Try Redis first + const [cachedData, lastUpdated] = await Promise.all([ + this.redis.get(cacheKey), + this.redis.get(`${cacheKey}:lastUpdated`) + ]); + + if (cachedData) { + logger.info('Realtime basic data found in Redis cache:', cachedData); + return { + ...JSON.parse(cachedData), + lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : new Date().toISOString() + }; + } + + // Fetch fresh data + logger.info(`Fetching fresh realtime data from GA4 server`); + const [userResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + metrics: [{ name: 'activeUsers' }], + returnPropertyQuota: true + }); + logger.info('GA4 user response:', userResponse); + + const [fiveMinResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + metrics: [{ name: 'activeUsers' }], + minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }] + }); + + const [timeSeriesResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + dimensions: [{ name: 'minutesAgo' }], + metrics: [{ name: 'activeUsers' }] + }); + + // Create new Analytics document + const analyticsDoc = await Analytics.create({ + type: 'realtime_basic', + data: { + userResponse, + fiveMinResponse, + timeSeriesResponse, + quotaInfo: { + projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour, + daily: userResponse.propertyQuota.tokensPerDay, + serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour, + thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour + } + }, + quotaInfo: userResponse.propertyQuota + }); + + const formattedData = analyticsDoc.formatResponse(); + + // Save to Redis + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Analytics.getCacheDuration('realtime_basic') + }); + + return formattedData; + } catch (error) { + logger.error('Detailed error in getRealTimeBasicData:', { + message: error.message, + stack: error.stack, + code: error.code, + response: error.response?.data + }); + throw error; + } +} + + async getRealTimeDetailedData() { + const cacheKey = 'analytics:realtime:detailed'; + logger.info('Fetching realtime detailed data'); + + try { + // Check Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + logger.info('Realtime detailed data found in Redis cache'); + return JSON.parse(cachedData); + } + + // Fetch fresh data from GA4 + const [pageResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + dimensions: [{ name: 'unifiedScreenName' }], + metrics: [{ name: 'screenPageViews' }], + orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }], + limit: 25 + }); + + const [eventResponse] = await this.analyticsClient.runRealtimeReport({ + property: `properties/${this.propertyId}`, + dimensions: [{ name: 'eventName' }], + metrics: [{ name: 'eventCount' }], + orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }], + limit: 25 + }); + + 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 + }); + + // Create new Analytics document + const analyticsDoc = await Analytics.create({ + type: 'realtime_detailed', + data: { + pageResponse, + eventResponse, + sourceResponse: deviceResponse + }, + quotaInfo: deviceResponse.propertyQuota + }); + + const formattedData = analyticsDoc.formatResponse(); + + // Save to Redis + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Analytics.getCacheDuration('realtime_detailed') + }); + + return formattedData; + } catch (error) { + logger.error('Error fetching realtime detailed data:', { + error: error.message, + stack: error.stack + }); + throw error; + } + } + + async getUserBehavior(params = {}) { + const cacheKey = `analytics:user_behavior:${JSON.stringify(params)}`; + const timeRange = params.timeRange || '7'; + + logger.info('Fetching user behavior data', { params }); + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + logger.info('User behavior data found in Redis cache'); + return JSON.parse(cachedData); + } + + // Check MongoDB using new findValidCache method + const mongoData = await Analytics.findValidCache('user_behavior', params); + + if (mongoData) { + logger.info('User behavior data found in MongoDB'); + const formattedData = mongoData.formatResponse(); + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Analytics.getCacheDuration('user_behavior') + }); + return formattedData; + } + + // Fetch fresh data from GA4 + logger.info('Fetching fresh user behavior data from GA4'); + + 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 + }); + + const [deviceResponse] = await this.analyticsClient.runReport({ + property: `properties/${this.propertyId}`, + dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }], + dimensions: [{ name: 'deviceCategory' }], + metrics: [ + { name: 'screenPageViews' }, + { name: 'sessions' } + ] + }); + + 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 + }); + + // Create new Analytics document + const analyticsDoc = await Analytics.create({ + type: 'user_behavior', + params, + data: { + pageResponse, + deviceResponse, + sourceResponse + }, + quotaInfo: sourceResponse.propertyQuota + }); + + const formattedData = analyticsDoc.formatResponse(); + + // Save to Redis + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Analytics.getCacheDuration('user_behavior') + }); + + return formattedData; + } catch (error) { + logger.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/examples DO NOT USE OR EDIT/EXAMPLE ONLY googleAnalyticsService.js b/examples DO NOT USE OR EDIT/EXAMPLE ONLY googleAnalyticsService.js new file mode 100644 index 0000000..6cc4177 --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY googleAnalyticsService.js @@ -0,0 +1,292 @@ +// src/services/googleAnalyticsService.js +class GoogleAnalyticsService { + constructor() { + this.baseUrl = "/api/analytics"; // This matches your NGINX config + } + + async getBasicMetrics({ startDate = "7daysAgo" } = {}) { + try { + const response = await fetch( + `${this.baseUrl}/metrics?startDate=${startDate}`, + { + credentials: "include", + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch metrics"); + } + + const result = await response.json(); + + if (!result?.data) { + throw new Error("No data received"); + } + + return this.processMetricsData(result.data); + } catch (error) { + console.error("Failed to fetch basic metrics:", error); + throw error; + } + } + + async getRealTimeBasicData() { + try { + const response = await fetch(`${this.baseUrl}/realtime/basic`, { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to fetch basic realtime data"); + } + + const result = await response.json(); + + if (!result?.data) { + throw new Error("No data received"); + } + + const processed = this.processRealTimeBasicData(result.data); + return { + ...processed, + lastUpdated: result.lastUpdated || new Date().toISOString(), + }; + } catch (error) { + console.error("Failed to fetch basic realtime data:", error); + throw error; + } + } + + async getRealTimeDetailedData() { + try { + const response = await fetch(`${this.baseUrl}/realtime/detailed`, { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to fetch detailed realtime data"); + } + + const result = await response.json(); + + if (!result?.data) { + throw new Error("No data received"); + } + + const processed = this.processRealTimeDetailedData(result.data); + return { + ...processed, + lastUpdated: result.lastUpdated || new Date().toISOString(), + }; + } catch (error) { + console.error("Failed to fetch detailed realtime data:", error); + throw error; + } + } + + async getUserBehavior(timeRange = "30") { + try { + const response = await fetch( + `${this.baseUrl}/user-behavior?timeRange=${timeRange}`, + { + credentials: "include", + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch user behavior"); + } + + const result = await response.json(); + console.log("Raw user behavior response:", result); + + if (!result?.success) { + throw new Error("Invalid response structure"); + } + + // Handle both data structures + const rawData = result.data?.data || result.data; + + // Try to access the data differently based on the structure + const pageResponse = rawData?.pageResponse || rawData?.reports?.[0]; + const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1]; + const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2]; + + console.log("Extracted responses:", { + pageResponse, + deviceResponse, + sourceResponse, + }); + + const processed = { + success: true, + data: { + pageData: { + pageData: this.processPageData(pageResponse), + deviceData: this.processDeviceData(deviceResponse), + }, + sourceData: this.processSourceData(sourceResponse), + }, + }; + + console.log("Final processed data:", processed); + return processed; + } catch (error) { + console.error("Failed to fetch user behavior:", error); + throw error; + } + } + + processMetricsData(data) { + if (!data?.rows) { + console.log("No rows found in data"); + return []; + } + + return data.rows.map((row) => ({ + date: row.dimensionValues[0].value, + activeUsers: parseInt(row.metricValues[0].value), + newUsers: parseInt(row.metricValues[1].value), + avgSessionDuration: parseFloat(row.metricValues[2].value), + pageViews: parseInt(row.metricValues[3].value), + bounceRate: parseFloat(row.metricValues[4].value) * 100, + conversions: parseInt(row.metricValues[5].value), + })); + } + + processRealTimeBasicData(data) { + const last30MinUsers = parseInt( + data.userResponse?.rows?.[0]?.metricValues?.[0]?.value || 0 + ); + const last5MinUsers = parseInt( + data.fiveMinResponse?.rows?.[0]?.metricValues?.[0]?.value || 0 + ); + + const byMinute = Array.from({ length: 30 }, (_, i) => { + const matchingRow = data.timeSeriesResponse?.rows?.find( + (row) => parseInt(row.dimensionValues[0].value) === i + ); + const users = matchingRow + ? parseInt(matchingRow.metricValues[0].value) + : 0; + const timestamp = new Date(Date.now() - i * 60000); + return { + minute: -i, + users, + timestamp: timestamp.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }), + }; + }).reverse(); + + const tokenQuota = data.quotaInfo + ? { + projectHourly: data.quotaInfo.projectHourly || {}, + daily: data.quotaInfo.daily || {}, + serverErrors: data.quotaInfo.serverErrors || {}, + thresholdedRequests: data.quotaInfo.thresholdedRequests || {}, + } + : null; + + return { + last30MinUsers, + last5MinUsers, + byMinute, + tokenQuota, + }; + } + + processRealTimeDetailedData(data) { + return { + currentPages: + data.pageResponse?.rows?.map((row) => ({ + page: row.dimensionValues[0].value, + views: parseInt(row.metricValues[0].value), + })) || [], + + sources: + data.sourceResponse?.rows?.map((row) => ({ + device: row.dimensionValues[0].value, + users: parseInt(row.metricValues[0].value), + })) || [], + + recentEvents: + data.eventResponse?.rows + ?.filter( + (row) => + !["session_start", "(other)"].includes( + row.dimensionValues[0].value + ) + ) + .map((row) => ({ + event: row.dimensionValues[0].value, + count: parseInt(row.metricValues[0].value), + })) || [], + }; + } + + processPageData(data) { + console.log("Processing page data input:", data); + if (!data?.rows) { + console.log("No rows in page data"); + return []; + } + + const processed = data.rows.map((row) => ({ + path: row.dimensionValues[0].value || "Unknown", + pageViews: parseInt(row.metricValues[0].value || 0), + avgSessionDuration: parseFloat(row.metricValues[1].value || 0), + bounceRate: parseFloat(row.metricValues[2].value || 0) * 100, + engagedSessions: parseInt(row.metricValues[3].value || 0), + })); + console.log("Processed page data:", processed); + return processed; + } + processDeviceData(data) { + console.log("Processing device data input:", data); + if (!data?.rows) { + console.log("No rows in device data"); + return []; + } + + const processed = data.rows + .filter((row) => { + const device = (row.dimensionValues[0].value || "").toLowerCase(); + return ["desktop", "mobile", "tablet"].includes(device); + }) + .map((row) => { + const device = row.dimensionValues[0].value || "Unknown"; + return { + device: + device.charAt(0).toUpperCase() + device.slice(1).toLowerCase(), + pageViews: parseInt(row.metricValues[0].value || 0), + sessions: parseInt(row.metricValues[1].value || 0), + }; + }) + .sort((a, b) => b.pageViews - a.pageViews); + + console.log("Processed device data:", processed); + return processed; + } + processSourceData(data) { + console.log("Processing source data input:", data); + if (!data?.rows) { + console.log("No rows in source data"); + return []; + } + + const processed = data.rows.map((row) => ({ + source: row.dimensionValues[0].value || "Unknown", + sessions: parseInt(row.metricValues[0].value || 0), + conversions: parseInt(row.metricValues[1].value || 0), + })); + console.log("Processed source data:", processed); + return processed; + } +} +// Create a single instance +const service = new GoogleAnalyticsService(); + +// Export both the instance and the class +export { service as googleAnalyticsService, GoogleAnalyticsService }; diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgias.service.js b/examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgias.service.js new file mode 100644 index 0000000..5031ed3 --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgias.service.js @@ -0,0 +1,129 @@ +const axios = require('axios'); +const { createClient } = require('redis'); +const Gorgias = require('../models/gorgias.model'); +const logger = require('../utils/logger'); + +class GorgiasService { + constructor() { + this.redis = createClient({ + url: process.env.REDIS_URL + }); + + this.redis.on('error', err => logger.error('Redis Client Error:', err)); + this.redis.connect().catch(err => logger.error('Redis connection error:', err)); + + this.apiClient = axios.create({ + baseURL: 'https://acherryontop.gorgias.com/api', + auth: { + username: process.env.GORGIAS_API_USERNAME, + password: process.env.GORGIAS_API_PASSWORD + } + }); + } + + async getStatistics(name, filters = {}) { + const cacheKey = `gorgias:stats:${name}:${JSON.stringify(filters)}`; + + logger.info(`Attempting to fetch statistics for ${name}`, { + filters, + cacheKey + }); + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + logger.info(`Statistics ${name} found in Redis cache`); + return JSON.parse(cachedData); + } + + // Check MongoDB + const mongoData = await Gorgias.findValidCache(name, filters); + if (mongoData) { + logger.info(`Statistics ${name} found in MongoDB`); + const formattedData = mongoData.formatResponse(); + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Gorgias.getCacheDuration(name) + }); + return formattedData; + } + + // Fetch from API + const response = await this.apiClient.post(`/stats/${name}`, { filters }); + + // Save to MongoDB + const doc = await Gorgias.create({ + type: name, + params: filters, + data: response.data + }); + + const formattedData = doc.formatResponse(); + + // Save to Redis + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Gorgias.getCacheDuration(name) + }); + + return formattedData; + } catch (error) { + logger.error(`Error in getStatistics for ${name}:`, { + error: error.message, + stack: error.stack, + filters, + response: error.response?.data + }); + throw error; + } +} + + async getTickets() { + const cacheKey = 'gorgias:tickets'; + + try { + // Try Redis first + const cachedData = await this.redis.get(cacheKey); + if (cachedData) { + logger.info('Tickets found in Redis cache'); + return JSON.parse(cachedData); + } + + // Check MongoDB + const mongoData = await Gorgias.findValidCache('tickets'); + if (mongoData) { + logger.info('Tickets found in MongoDB'); + const formattedData = mongoData.formatResponse(); + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Gorgias.getCacheDuration('tickets') + }); + return formattedData; + } + + // Fetch from API + const response = await this.apiClient.get('/tickets'); + + // Save to MongoDB + const doc = await Gorgias.create({ + type: 'tickets', + data: response.data + }); + + const formattedData = doc.formatResponse(); + + // Save to Redis + await this.redis.set(cacheKey, JSON.stringify(formattedData), { + EX: Gorgias.getCacheDuration('tickets') + }); + + return formattedData; + } catch (error) { + logger.error('Error fetching tickets:', { + error: error.message, + stack: error.stack + }); + throw error; + } + } +} + +module.exports = new GorgiasService(); diff --git a/examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgiasService.js b/examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgiasService.js new file mode 100644 index 0000000..8b347b3 --- /dev/null +++ b/examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgiasService.js @@ -0,0 +1,39 @@ +// src/services/gorgiasService.js +import axios from "axios"; + +const API_BASE_URL = "/api/gorgias"; + +// Helper function for consistent error handling +const handleError = (error, context) => { + console.error(`Error ${context}:`, error.response?.data || error.message); + throw error; +}; + +// Export the service object directly +const gorgiasService = { + async fetchTickets() { + try { + const response = await axios.get(`${API_BASE_URL}/tickets`); + return response.data.data || []; + } catch (error) { + handleError(error, "fetching tickets"); + } + }, + + async fetchStatistics(name, filters = {}) { + if (!name) { + throw new Error("Statistic name is required"); + } + + try { + const response = await axios.post(`${API_BASE_URL}/stats/${name}`, { + filters, + }); + return response.data; + } catch (error) { + handleError(error, `fetching statistics: ${name}`); + } + }, +}; + +export default gorgiasService; diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..9d58d68 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,27 @@ +# Gorgias API endpoints +location /api/gorgias/ { + proxy_pass http://localhost:3006/api/gorgias/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # CORS headers + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; + add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization'; + + # Handle OPTIONS method + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; + add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization'; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + add_header 'Content-Length' 0; + return 204; + } +} \ No newline at end of file