Merge pull request 'dev' (#2) from dev into main

Reviewed-on: #2
This commit is contained in:
igodin 2026-07-27 18:37:37 +00:00
commit d8253631f1
17 changed files with 1661 additions and 294 deletions

View File

@ -1,8 +1,44 @@
# sdp-request-watcher
A locally run JS app to monitor for request closures using Zoho's SDP Cloud API.
## Reference Materials
## Env and Server Setup
env.example contains the setup for the env requires a Non-Browser Application key from https://api-console.zoho.com/add
Place the .env file inside the src directory
Technicians and Statuses are checked in the .env file
I resolved the issue with it only finding closed tickets so it should now find resolved and closed I havent tested with others but will
The technician field can have spaces after the comma however the statuses can not. There can be no spaces before the status or it wont work properly.
## Site Setup
Open the site after the server is running, then connect the Zoho account from the board.
1. Start the server from the `src` folder with node ./server.js
2. Open the local dashboard in your browser. Should be localhost:3000
3. Click **Connect** to begin the Zoho authorization flow.
4. Approve the device code on the Zoho page, then return to the dashboard.
What you should see:
![Site setup step 1](images/1.png)
![Site setup step 2](images/2.png)
![Site setup step 3](images/3.png)
![Site setup step 4](images/4.png)
Once connected, the board will begin checking for tickets automatically and refreshing on its own based on the time set in the server.js file.
## Requirements
axios
dotenv
## Reference Materials/ Provided by Skylar
- [Non-Browser Apps - Overview](https://www.zoho.com/accounts/protocol/oauth/limited-input-devices.html)
- [Non-Browser Apps - Initiation Request](https://www.zoho.com/accounts/protocol/oauth/devices/initiation-request.html)

BIN
images/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
images/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
images/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
images/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

788
package-lock.json generated
View File

@ -1,12 +1,51 @@
{
"name": "sdp-request-watcher",
"name": "Dashboard",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"axios": "^1.13.2",
"dotenv": "^17.2.3"
"dotenv": "^17.4.2",
"express": "^5.2.1"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/accepts/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/accepts/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/asynckit": {
@ -26,6 +65,52 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -39,6 +124,22 @@
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -51,6 +152,63 @@
"node": ">= 0.8"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -60,10 +218,19 @@
"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/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@ -86,6 +253,21 @@
"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",
@ -131,6 +313,110 @@
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
@ -167,6 +453,24 @@
"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": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -264,6 +568,63 @@
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -273,6 +634,31 @@
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -294,11 +680,403 @@
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "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.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/send/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/type-is/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/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/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/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

View File

@ -1,6 +1,7 @@
{
"dependencies": {
"axios": "^1.13.2",
"dotenv": "^17.2.3"
"dotenv": "^17.4.2",
"express": "^5.2.1"
}
}

80
src/auth.js Normal file
View File

@ -0,0 +1,80 @@
const axios = require('axios');
async function getDeviceCode(clientId) {
const params = new URLSearchParams({
client_id: clientId,
scope: 'SDPOnDemand.requests.READ',
grant_type: 'device_request',
access_type: 'offline'
});
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/code', params);
return res.data;
}
function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) {
const pollParams = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code: deviceCode,
grant_type: 'device_token'
});
return new Promise((resolve, reject) => {
const deadline = Date.now() + (expiresIn || 300) * 1000;
const timer = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(timer);
reject(new Error('Expired try again.'));
return;
}
try {
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
if (res.data.access_token) {
clearInterval(timer);
resolve(res.data.access_token);
}
} catch (err) {
}
}, interval + 500);
});
}
async function startAuthentication({ clientId, clientSecret, state }) {
state.auth = 'pending';
state.lastError = null;
const deviceData = await getDeviceCode(clientId);
state.verificationUrl = deviceData.verification_uri_complete;
state.userCode = deviceData.user_code;
console.log('Open this URL and authorize:', deviceData.verification_uri_complete);
console.log('Code: ', deviceData.user_code);
try {
const accessToken = await pollForToken(
clientId,
clientSecret,
deviceData.device_code,
deviceData.interval,
deviceData.expires_in
);
state.auth = 'connected';
state.verificationUrl = null;
state.userCode = null;
return { accessToken };
} catch (err) {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
return null;
}
}
module.exports = {
startAuthentication
};

89
src/boardService.js Normal file
View File

@ -0,0 +1,89 @@
const { startAuthentication } = require('./auth');
const { fetchClosedTickets } = require('./tickets');
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
const state = {
auth: 'disconnected',
verificationUrl: null,
userCode: null,
lastCheck: null,
lastError: null
};
const tickets = new Map();
let accessToken = null;
let pollingTimer = null;
async function refreshTickets() {
if (!accessToken) return;
await fetchClosedTickets({
accessToken,
state,
tickets,
technicians,
statusSet,
windowMinutes
});
}
function startPolling() {
if (pollingTimer) return;
pollingTimer = setInterval(() => {
refreshTickets().catch(err => {
state.lastError = err.message;
state.lastCheck = Date.now();
});
}, checkMinutes * 60 * 1000);
}
async function connect() {
const authResult = await startAuthentication({
clientId,
clientSecret,
state
});
if (!authResult?.accessToken) return;
accessToken = authResult.accessToken;
console.log('Authorization successful');
await refreshTickets();
startPolling();
}
function failConnection(err) {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
}
function getState() {
return {
...state,
windowMinutes,
checkMinutes,
watching: technicians,
serverTime: Date.now(),
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
};
}
async function checkNow() {
await refreshTickets();
}
return {
connect,
checkNow,
failConnection,
getState
};
}
module.exports = {
createBoardService
};

4
src/env.example Normal file
View File

@ -0,0 +1,4 @@
CLIENT_ID=
CLIENT_SECRET=
TECHNICIAN_NAMES=First Last, First Last
STATUS_NAMES=Closed,Resolved

View File

@ -1,55 +0,0 @@
/* SDP Request Watcher
* Monitors requests in Zoho's SDP Cloud platform to play a sound alert
* when a request is closed.
*
* Developed for MCCS ITS by Skylar Grant
*/
const dotenv = require('dotenv');
dotenv.config({ quiet: true });
const { CLIENT_ID, CLIENT_SECRET } = process.env;
const { Client, ListInfoBuilder, SearchCriteriaBuilder } = require('./modules/sdp-api');
const client = new Client(CLIENT_ID, CLIENT_SECRET);
const technicianNamesArray = process.env.TECHNICIAN_NAMES.split(',');
const statusNamesArray = process.env.STATUS_NAMES.split(',');
async function authInitLoop() {
await client.init();
console.log(`Please visit ${client.verificationUrl} and enter the code: ${client.userCode}`);
// Poll for authorization
const pollInterval = setInterval(async () => {
try {
/** @type {boolean} */
const authorized = await client.poll();
if (authorized) {
clearInterval(pollInterval);
console.log('Authorization successful! Access token obtained.');
} else {
console.log('Still waiting for user authorization...');
}
} catch (error) {
clearInterval(pollInterval);
console.error('Error during polling:', error.message);
}
}, client.pollInterval + 500);
}
async function pullRequests() {
try {
const listInfo = new ListInfoBuilder()
.setRowCount(10);
const searchCriteria = new SearchCriteriaBuilder()
.addTechnicians(technicianNamesArray, "is")
.addStatuses(statusNamesArray, "is", "and");
listInfo.data.search_criteria = searchCriteria.data;
} catch (error) {
console.error('Error pulling requests:', error.message);
}
}
authInitLoop().catch(err => {
console.error('Error in authInitLoop execution:', err.message);
});

View File

@ -1,231 +0,0 @@
/* SDP-API Tool
* Client for interacting with the Zoho SDP Cloud API.
*
* Developed for MCCS ITS by Skylar Grant
*/
const axios = require('axios');
// Template strings for requests
const strings = {
tokenPrefix: 'Authorization: Zoho-oauthtoken ',
initiation: {
endpoint: 'https://accounts.zoho.com/oauth/v3/device/code',
params: {
grant_type: 'device_request',
scope: 'SDPOnDemand.requests.READ',
access_type: 'offline'
}
},
polling: {
endpoint: 'https://accounts.zoho.com/oauth/v3/device/token',
params: {
grant_type: 'device_token'
}
}
}
/** SDP API Client
* Handles OAuth device authorization flow for Zoho's SDP Cloud API.
* @class Client
* @constructor
* @param {string} clientId - The Client ID for the Zoho application.
* @param {string} clientSecret - The Client Secret for the Zoho application.
* @returns {Client} An instance of the SDP API Client.
*/
class Client {
constructor(clientId, clientSecret) {
if (!clientId || !clientSecret) {
throw new Error('Client ID and Client Secret are required to create an SDP API Client.');
}
this.clientId = clientId;
this.clientSecret = clientSecret;
// Set up placeholders for tokens and codes
this.deviceCode = null;
this.accessToken = null;
this.refreshToken = null;
this.userCode = null;
this.verificationUrl = null;
// Create copies of the request parameter templates
this.initParams = JSON.parse(JSON.stringify(strings.initiation.params));
this.initParams.client_id = this.clientId;
this.pollParams = JSON.parse(JSON.stringify(strings.polling.params));
this.pollParams.client_id = this.clientId;
this.pollParams.client_secret = this.clientSecret;
return this;
}
async init() {
const urlParams = new URLSearchParams(this.initParams).toString();
const fullUrl = `${strings.initiation.endpoint}?${urlParams}`;
const initRes = await axios.post(fullUrl);
if (initRes.status !== 200) {
throw new Error(`Error during initiation request: ${initRes.status} - ${initRes.statusText}`);
}
this.deviceCode = initRes.data.device_code;
this.userCode = initRes.data.user_code;
this.verificationUrl = initRes.data.verification_uri_complete;
this.pollInterval = initRes.data.interval;
return this;
}
async poll() {
if (!this.deviceCode) {
throw new Error('Device code is not set. Please run the initiation request first.');
}
this.pollParams.code = this.deviceCode;
const pollParams = new URLSearchParams(this.pollParams).toString();
const fullPollUrl = `${strings.polling.endpoint}?${pollParams}`;
const pollRes = await axios.post(fullPollUrl);
if (pollRes.status !== 200) {
throw new Error(`Error during polling request: ${pollRes.status} - ${pollRes.statusText}`);
}
if (pollRes.data.access_token) {
this.accessToken = pollRes.data.access_token;
this.refreshToken = pollRes.data.refresh_token;
return true;
} else {
return false;
}
}
}
/** Search Criteria Builder
* Class to help build search criteria for SDP API requests.
* @class SearchCriteriaBuilder
* @constructor
* @returns {SearchCriteriaBuilder} An instance of the Search Criteria Builder.
*/
class SearchCriteriaBuilder {
constructor() {
this.data = new Array();
return this;
}
/** Add technician names to the search criteria.
* @param {Array<String>} technicianNamesArray Array of technician names
* @param {String} condition Condition to use for filtering, "is" or "is not" (Default: "is")
* @param {String} operator Operator to use for filtering, "and" or "or" (Optional if first/only criterion)
* @returns {SearchCriteriaBuilder}
*/
addTechnicians(technicianNamesArray, condition, operator) {
if (!Array.isArray(technicianNamesArray) || technicianNamesArray.length === 0) {
throw new Error('Technician names must be a non-empty array.');
}
condition = condition || 'is';
const newCriterion = {
field: 'technician.name',
condition: condition,
values: technicianNamesArray
};
if (operator) {
newCriterion.logical_operator = operator;
}
this.data.push(newCriterion);
return this;
}
/** Add status names to the search criteria.
* @param {Array<String>} statusesArray Array of status names
* @param {String} condition Condition to use for filtering, "is" or "is not" (Default: "is not")
* @param {String} operator Operator to use for filtering, "and" or "or" (Optional if first/only criterion)
* @returns {SearchCriteriaBuilder}
*/
addStatuses(statusesArray, condition, operator) {
if (!Array.isArray(statusesArray) || statusesArray.length === 0) {
throw new Error('Statuses must be a non-empty array.');
}
condition = condition || 'is not';
const newCriterion = {
field: 'status.internal_name',
condition: condition,
values: statusesArray
};
if (operator) {
newCriterion.logical_operator = operator;
}
this.data.push(newCriterion);
return this;
}
}
/** List Info Builder
* Class to help build list info for SDP API requests.
* @class ListInfoBuilder
* @constructor
* @returns {ListInfoBuilder} An instance of the List Info Builder.
*/
class ListInfoBuilder {
constructor() {
this.data = {
row_count: 100,
start_index: 0,
sort_field: 'last_updated_time',
sort_order: 'desc',
get_total_count: true
};
return this;
}
setRowCount(count) {
if (typeof count !== 'number' || count <= 0) {
throw new Error('Row count must be a positive number.');
}
this.data.row_count = count;
return this;
}
setStartIndex(index) {
if (typeof index !== 'number' || index < 0) {
throw new Error('Start index must be a non-negative number.');
}
this.data.start_index = index;
return this;
}
setSortField(field) {
if (typeof field !== 'string' || field.trim() === '') {
throw new Error('Sort field must be a non-empty string.');
}
this.data.sort_field = field;
return this;
}
setSortOrder(order) {
if (order !== 'asc' && order !== 'desc') {
throw new Error('Sort order must be either "asc" or "desc".');
}
this.data.sort_order = order;
return this;
}
enableTotalCount(enable) {
this.data.get_total_count = Boolean(enable);
return this;
}
setSearchCriteria(criteriaArray) {
if (!Array.isArray(criteriaArray)) {
throw new Error('Search criteria must be an array.');
}
this.data.search_criteria = criteriaArray;
return this;
}
}
module.exports = {
Client,
SearchCriteriaBuilder,
ListInfoBuilder
}

BIN
src/public/ding.mp3 Normal file

Binary file not shown.

481
src/public/index.html Normal file
View File

@ -0,0 +1,481 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Closed ticket board</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
:root {
--ground: hsl(210, 18%, 8%);
--card: hsl(210, 16%, 13%);
--ink: hsl(30, 30%, 96%);
--muted: hsl(215, 12%, 68%);
--rule: hsl(210, 12%, 24%);
--signal: hsl(28, 95%, 58%);
--signal-soft: hsl(28, 70%, 22%);
--alert: hsl(14, 90%, 58%);
--font-ui: 'Outfit', system-ui, -apple-system, 'Segoe UI', sans-serif;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: var(--font-ui);
background: var(--ground);
color: var(--ink);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 860px;
flex: 1;
margin: 0 auto;
padding: 32px 20px 80px;
}
/* ---- header ---- */
header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 12px 20px;
padding-bottom: 14px;
border-bottom: 2px solid var(--ink);
}
h1 {
margin: 0;
font-family: var(--mono);
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
}
.status {
display: flex;
align-items: center;
gap: 7px;
margin-left: auto;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
flex: none;
}
.status[data-auth="connected"] .dot {
background: var(--signal);
animation: pulse 2.4s ease-in-out infinite;
}
.status[data-auth="error"] .dot { background: var(--alert); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin: 12px 0 0;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
/* ---- panels ---- */
.panel {
margin-top: 24px;
padding: 24px;
background: var(--card);
border: 1px solid var(--rule);
}
.panel h2 {
margin: 0 0 6px;
font-size: 16px;
font-weight: 600;
}
.panel p {
margin: 0 0 16px;
color: var(--muted);
max-width: 52ch;
}
.code {
display: inline-block;
margin-bottom: 16px;
padding: 10px 16px;
background: var(--signal-soft);
font-family: var(--mono);
font-size: 26px;
font-weight: 600;
letter-spacing: 0.12em;
}
button, .button {
display: inline-block;
padding: 9px 18px;
border: none;
background: var(--ink);
color: var(--ground);
font-family: var(--font-ui);
font-size: 14px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
}
button:hover, .button:hover { background: var(--signal); }
button:focus-visible, .button:focus-visible,
a:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }
button.quiet {
background: none;
color: var(--muted);
padding: 4px 0;
font-family: var(--mono);
font-size: 12px;
text-decoration: underline;
text-underline-offset: 3px;
}
button.quiet:hover { background: none; color: var(--ink); }
.error {
margin-top: 20px;
padding: 12px 16px;
background: var(--card);
border-left: 3px solid var(--alert);
font-size: 14px;
}
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
/* ---- tallies ---- */
.tallies {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 24px;
}
.tally {
display: flex;
align-items: baseline;
gap: 8px;
padding: 5px 12px;
background: var(--card);
border: 1px solid var(--rule);
font-size: 13px;
}
.tally b {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
color: var(--signal);
}
/* ---- feed ---- */
.feed { margin-top: 20px; }
.ticket {
--age: 0;
display: grid;
grid-template-columns: 74px 1fr;
background: var(--card);
border: 1px solid var(--rule);
border-bottom: none;
}
.feed .ticket:last-child { border-bottom: 1px solid var(--rule); }
/* The rail is the point of the whole page: how long ago this closed, drained
of color as it ages out of the window. */
.rail {
display: flex;
flex-direction: column;
justify-content: center;
padding: 14px 0 14px 14px;
border-left: 3px solid color-mix(in oklab, var(--signal), var(--rule) calc(var(--age) * 100%));
font-family: var(--mono);
font-size: 15px;
font-weight: 500;
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
}
.rail small {
font-size: 10px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.body { padding: 14px 16px 14px 4px; min-width: 0; }
.subject {
margin: 0;
font-size: 15px;
font-weight: 500;
overflow-wrap: anywhere;
}
.subject span {
font-family: var(--mono);
font-weight: 600;
color: var(--muted);
margin-right: 8px;
}
.byline {
margin: 3px 0 0;
font-size: 13px;
color: var(--muted);
}
.byline b { color: var(--ink); font-weight: 500; }
.ticket.new { animation: slide 0.35s ease-out; }
@keyframes slide {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
.empty {
margin-top: 20px;
padding: 40px 24px;
background: var(--card);
border: 1px dashed var(--rule);
text-align: center;
color: var(--muted);
}
.empty p { margin: 0; }
footer {
margin-top: 28px;
font-family: var(--font-ui);
font-size: 11px;
color: var(--ink);
}
body > footer {
margin-top: auto;
max-width: 860px;
width: 100%;
padding: 0 20px 28px;
}
@media (max-width: 520px) {
.ticket { grid-template-columns: 62px 1fr; }
.rail { font-size: 13px; padding-left: 10px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Closed ticket board</h1>
<div class="status" id="status" data-auth="disconnected">
<span class="dot"></span><span id="statusText">Not connected</span>
</div>
</header>
<div class="meta" id="meta"></div>
<div id="main"></div>
<footer id="footer"></footer>
</div>
<script>
const $ = id => document.getElementById(id);
const ding = new Audio('/ding.mp3');
ding.preload = 'auto';
ding.volume = 0.5;
function beep() {
ding.currentTime = 0;
ding.play().catch(() => {
});
}
let data = null;
let seen = new Set();
let firstLoad = true;
const STATUS_TEXT = {
disconnected: 'Not connected',
pending: 'Waiting for authorization',
connected: 'Live',
error: 'Stopped'
};
function elapsed(ms) {
const mins = Math.floor(ms / 60000);
if (mins < 1) return { value: 'now', label: 'just closed' };
if (mins < 60) return { value: mins + 'm', label: 'ago' };
const hrs = Math.floor(mins / 60);
return { value: hrs + 'h ' + (mins % 60) + 'm', label: 'ago' };
}
function clockTime(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function esc(text) {
return String(text ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
async function load() {
try {
const res = await fetch('/api/state');
data = await res.json();
render();
} catch (err) {
$('statusText').textContent = 'Server unreachable';
}
}
function render() {
if (!data) return;
if (!firstLoad && data.tickets.some(t => !seen.has(t.id))) beep(); //Calls Beep
$('status').dataset.auth = data.auth;
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
$('meta').innerHTML = [
'Checks every ' + data.checkMinutes + ' min',
'Window ' + data.windowMinutes + ' min',
data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians',
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
let html = '';
if (data.auth === 'disconnected' || data.auth === 'error') {
html += `<div class="panel">
<h2>Connect to the help desk</h2>
<p>Zoho needs you to approve this board once. Connecting opens an authorization
page and starts the checks as soon as you approve.</p>
<button onclick="connect(this)">Connect</button>
</div>`;
}
if (data.auth === 'pending') {
html += `<div class="panel">
<h2>Approve the board</h2>
<p>Open the authorization page and confirm this code matches the one shown there.</p>
<div class="code">${esc(data.userCode || '...')}</div>
<a class="button" href="${esc(data.verificationUrl || '#')}">Open authorization page</a>
</div>`;
}
if (data.lastError) {
html += `<div class="error"><strong>Last check failed</strong>${esc(data.lastError)}</div>`;
}
if (data.auth === 'connected') {
const tallies = {};
for (const t of data.tickets) tallies[t.technician] = (tallies[t.technician] || 0) + 1;
const names = Object.keys(tallies).sort((a, b) => tallies[b] - tallies[a]);
if (names.length) {
html += '<div class="tallies">' + names.map(n =>
`<span class="tally">${esc(n)} <b>${tallies[n]}</b></span>`).join('') + '</div>';
}
if (!data.tickets.length) {
html += `<div class="empty"><p>Nothing closed yet.</p></div>`;
} else {
html += '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>';
}
html += '<p style="margin-top:16px"><button class="quiet" onclick="checkNow(this)">Check now</button></p>';
}
$('main').innerHTML = html;
$('footer').textContent = data.tickets && data.tickets.length
? data.tickets.length + ' tickets on the board'
: '';
firstLoad = false;
}
function renderTicket(ticket) {
const age = Date.now() - ticket.closedAt;
const e = elapsed(age);
// 0 = just closed, 1 = fully aged out (one hour on)
const fade = Math.min(age / (60 * 60 * 1000), 1).toFixed(3);
const isNew = !firstLoad && !seen.has(ticket.id);
seen.add(ticket.id);
return `<article class="ticket${isNew ? ' new' : ''}" style="--age:${fade}">
<div class="rail">
${esc(e.value)}
<small>${esc(e.label)}</small>
</div>
<div class="body">
<p class="subject"><span>#${esc(ticket.id)}</span>${esc(ticket.subject || 'No subject')}</p>
<p class="byline"><b>${esc(ticket.technician)}</b> &middot; closed ${esc(ticket.closedDisplay || clockTime(ticket.closedAt))}</p>
</div>
</article>`;
}
async function connect(btn) {
btn.disabled = true;
btn.textContent = 'Connecting...';
await fetch('/api/connect', { method: 'POST' });
load();
}
async function checkNow(btn) {
btn.disabled = true;
btn.textContent = 'Checking...';
await fetch('/api/check', { method: 'POST' });
load();
}
load();
setInterval(load, 5000); // pull fresh state from the server
setInterval(render, 20000); // re-age the rails between pulls
</script>
<footer>
<p>© 2026 <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span>. All rights reserved.</p>
<p>Design and Credits: <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span></p>
</footer>
</body>
</html>

37
src/routes.js Normal file
View File

@ -0,0 +1,37 @@
const express = require('express');
function createRoutes(boardService) {
const router = express.Router();
router.get('/state', (req, res) => {
res.json(boardService.getState());
});
router.post('/connect', async (req, res) => {
const currentState = boardService.getState();
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
return res.json({ ok: true });
}
try {
boardService.connect().catch(err => {
boardService.failConnection(err);
});
res.json({ ok: true });
} catch (err) {
boardService.failConnection(err);
res.status(500).json({ ok: false, error: err.message });
}
});
router.post('/check', async (req, res) => {
await boardService.checkNow();
res.json({ ok: true });
});
return router;
}
module.exports = {
createRoutes
};

43
src/server.js Normal file
View File

@ -0,0 +1,43 @@
require('dotenv').config({ quiet: true });
const path = require('path');
const express = require('express');
const { createBoardService } = require('./boardService');
const { createRoutes } = require('./routes');
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
const PORT = process.env.PORT || 3000; // default 3000
if (!CLIENT_ID || !CLIENT_SECRET) {
console.error('Client id and secret missing from .env');
process.exit(1);
}
const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t => t.trim()).filter(Boolean);
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
const WINDOW_MINUTES = 10; // how far back the server looks in minutes
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
const app = express();
//creates the board service with the config specified
const boardService = createBoardService({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
technicians,
statusSet,
windowMinutes: WINDOW_MINUTES,
checkMinutes: CHECK_MINUTES
});
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api', createRoutes(boardService));
//starts listening on port specified
app.listen(PORT, () => {
console.log(`Open http://localhost:${PORT}`);
});

104
src/tickets.js Normal file
View File

@ -0,0 +1,104 @@
const axios = require('axios');
function toMillis(completedTime) {
if (!completedTime) return null;
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
const ms = Number(raw);
return Number.isFinite(ms) ? ms : null;
}
function getRelevantTicketTime(ticket) {
return toMillis(ticket.completed_time)
|| toMillis(ticket.resolved_time)
|| toMillis(ticket.last_updated_time)
|| null;
}
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, windowMinutes }) {
if (!accessToken) return;
try {
const windowStart = Date.now() - windowMinutes * 60 * 1000;
const listInfo = {
list_info: {
row_count: 200,
sort_field: 'last_updated_time',
sort_order: 'desc',
fields_required: [
'display_id',
'subject',
'status',
'completed_time',
'resolved_time',
'last_updated_time',
'technician'
],
search_criteria: [
{
field: 'last_updated_time',
condition: 'greater_than',
value: windowStart.toString()
}
]
}
};
const response = await axios.get('https://ithelp.mainecc.edu/api/v3/requests', {
params: { input_data: JSON.stringify(listInfo) },
headers: {
Authorization: `Zoho-oauthtoken ${accessToken}`,
Accept: 'application/vnd.manageengine.sdp.v3+json'
}
});
const found = response.data?.requests || [];
for (const ticket of found) {
const statusName = ticket.status?.name || '';
const statusInternalName = ticket.status?.internal_name || '';
const statusMatches = !statusSet.size
|| statusSet.has(statusName.toLowerCase())
|| statusSet.has(statusInternalName.toLowerCase());
if (!statusMatches) continue;
const closedAt = getRelevantTicketTime(ticket);
if (!closedAt || closedAt <= windowStart) continue;
const technician = ticket.technician ? ticket.technician.name : null;
if (!technician) continue;
if (technicians.length && !technicians.includes(technician)) continue;
tickets.set(String(ticket.display_id), {
id: ticket.display_id,
subject: ticket.subject,
technician,
status: statusName || null,
closedAt,
closedDisplay: ticket.completed_time?.display_value
|| ticket.resolved_time?.display_value
|| ticket.last_updated_time?.display_value
|| null
});
}
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
for (const [key, ticket] of tickets) {
if (ticket.closedAt < cutoff) tickets.delete(key);
}
state.lastCheck = Date.now();
state.lastError = null;
} catch (err) {
const detail = err.response?.data;
state.lastError = detail?.response_status?.messages?.[0]?.message
|| (typeof detail === 'string' ? detail : null)
|| err.message;
state.lastCheck = Date.now();
console.error('Error: ', detail || err.message);
}
}
module.exports = {
fetchClosedTickets
};